Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744d5e488d | ||
|
|
7f2a91528f | ||
|
|
e1791e16e9 | ||
|
|
93a0e3ac20 | ||
|
|
ddabaf0edd | ||
|
|
92b3bf64bb | ||
|
|
368a06c30f | ||
|
|
9bac65ad12 | ||
|
|
bcd6468cf2 | ||
|
|
c92834f693 | ||
|
|
236ec391b0 | ||
|
|
602c063493 | ||
|
|
cbb4566f47 | ||
|
|
ca31722252 | ||
|
|
af49be487f | ||
|
|
e889e938d1 | ||
|
|
92b2a2ba74 | ||
|
|
4f1ed7734d | ||
|
|
13688b4429 | ||
|
|
ca67fcf6c9 | ||
|
|
3a5a68c9b3 | ||
|
|
a219187da1 | ||
|
|
c842b92d12 | ||
|
|
184f002e0b | ||
|
|
51b473ce7a | ||
|
|
3f86f1f5ea | ||
|
|
cdc2920fdf | ||
|
|
2e1a6788bf | ||
|
|
fbae3ac1e2 | ||
|
|
8ca8567680 | ||
|
|
a4bfc9087e | ||
|
|
8d4d287080 | ||
|
|
3a7bf22904 | ||
|
|
4625012ee9 | ||
|
|
2673203d48 | ||
|
|
31ae9463a5 | ||
|
|
c4292f58ec | ||
|
|
a3b1a3ff8b | ||
|
|
46ad4859a5 | ||
|
|
90a6373f2e | ||
|
|
9af6f93907 | ||
|
|
d49ee44212 | ||
|
|
75389e8d34 | ||
|
|
f349a886a3 | ||
|
|
ecaad4514f | ||
|
|
4444a09c8b | ||
|
|
dfadb80e2c |
@@ -1,51 +0,0 @@
|
||||
name: sync
|
||||
# The ONLY GitHub Action this repo is allowed to run.
|
||||
#
|
||||
# git.hanzo.ai is CANONICAL. GitHub is a mirror. Everything that BUILDS,
|
||||
# PUBLISHES or DEPLOYS is native — .hanzo/workflows/image.yml on the forge for
|
||||
# images, and platform.hanzo.ai / hanzo-cd for rollout. GitHub Actions must
|
||||
# never build, never push an image, and never touch a cluster; its single job is
|
||||
# to get commits onto the forge so the native pipeline can see them.
|
||||
#
|
||||
# Direction is push: GitHub -> git.hanzo.ai. The forge also runs a pull job
|
||||
# (.hanzo/workflows/sync-from-github.yml); the two compose rather than fight,
|
||||
# because whichever arrives second sees LOCAL == REMOTE and exits a no-op.
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (full history + tags — a shallow push loses commits)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: tree:0
|
||||
|
||||
- name: Push to git.hanzo.ai
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
FORGE_USER: ${{ vars.FORGE_USER || 'hanzo-dev' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
echo "::error::FORGE_TOKEN is not set — refusing to sync silently."
|
||||
exit 1
|
||||
fi
|
||||
# Credential stays in the helper, never in a remote URL: a token baked
|
||||
# into .git/config leaks on any `git remote -v`.
|
||||
git config --global credential.helper store
|
||||
printf 'https://%s:%s@git.hanzo.ai\n' "$FORGE_USER" "$FORGE_TOKEN" > ~/.git-credentials
|
||||
chmod 600 ~/.git-credentials
|
||||
git remote add forge "https://git.hanzo.ai/${GITHUB_REPOSITORY}.git" 2>/dev/null || \
|
||||
git remote set-url forge "https://git.hanzo.ai/${GITHUB_REPOSITORY}.git"
|
||||
git push forge --follow-tags "HEAD:${GITHUB_REF}"
|
||||
rm -f ~/.git-credentials
|
||||
@@ -0,0 +1,15 @@
|
||||
# Canonical caller — every knob lives in /hanzo.yml, none here.
|
||||
# Runs on the git-runner fleet at git.hanzo.ai, the only pool serving these
|
||||
# labels. github.com resolves only .github/workflows and has no runner for
|
||||
# them, so a caller placed there is a gate that cannot be scheduled.
|
||||
name: CI/CD
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
@@ -1,7 +1,7 @@
|
||||
name: image
|
||||
# THE builder for ghcr.io/hanzoai/iam from main, on Hanzo's own forge.
|
||||
#
|
||||
# git.hanzo.ai push (incl. mirror-sync from GitHub) → act_runner
|
||||
# git.hanzo.ai push (from GitHub via sync-from-github.yml) → act_runner
|
||||
# → buildx → ghcr.io/hanzoai/iam:sha-<7>
|
||||
#
|
||||
# WHY THIS FILE IS NOT CALLED build.yml. Gitea collects workflows from the FIRST
|
||||
@@ -114,13 +114,36 @@ jobs:
|
||||
# commit newer than the last built release and older than two tagged ones,
|
||||
# so the estate's IdP ran code no version named. A release is a version.
|
||||
# Nothing else earns a name in the registry.
|
||||
#
|
||||
# Three outputs, each with ONE meaning, because the single `tag` output
|
||||
# they replace had two: a bare `v1.34.6` on a tag push but a WHOLE image
|
||||
# ref on a branch push. Every consumer then had to know which case it was
|
||||
# in, and the verify step below did not — it prefixed the repo again and
|
||||
# asked the registry for `ghcr.io/hanzoai/iam:ghcr.io/hanzoai/iam:
|
||||
# unpublished`, which cannot resolve, so it burned its six retries and
|
||||
# failed the job. Every push to main was red, on a builder that had in
|
||||
# fact built the image correctly.
|
||||
#
|
||||
# version — what the binary reports (`/iam version`)
|
||||
# image — the full destination ref
|
||||
# push — whether this ref is published at all
|
||||
- id: meta
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) tag="${GITHUB_REF#refs/tags/}"; push=true ;;
|
||||
*) tag="ghcr.io/hanzoai/iam:unpublished"; push=false ;;
|
||||
refs/tags/v*)
|
||||
version="${GITHUB_REF#refs/tags/}"
|
||||
image="ghcr.io/hanzoai/iam:${version}"
|
||||
push=true ;;
|
||||
*)
|
||||
# An unpublished build is honestly `dev` — an empty VERSION would
|
||||
# override the Dockerfile's `ARG VERSION=dev` with nothing and
|
||||
# link a blank version into the binary.
|
||||
version=dev
|
||||
image="ghcr.io/hanzoai/iam:unpublished"
|
||||
push=false ;;
|
||||
esac
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||
echo "push=$push" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
@@ -141,15 +164,15 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.meta.outputs.push }}
|
||||
provenance: false
|
||||
tags: ${{ startsWith(github.ref, 'refs/tags/v') && format('ghcr.io/hanzoai/iam:{0}', steps.meta.outputs.tag) || steps.meta.outputs.tag }}
|
||||
tags: ${{ steps.meta.outputs.image }}
|
||||
# The Dockerfile already carries `-X main.version=${VERSION}`, but its
|
||||
# `ARG VERSION=dev` default was never overridden here — so every image
|
||||
# this builder shipped reported `iam dev` from `/iam version` and could
|
||||
# not name its own lineage. Measured on the live pod, 2026-07-27. Pass
|
||||
# the SAME string used as the image tag: `iam sha-d2aa268` then states
|
||||
# exactly which artifact is running, with no second identifier to drift.
|
||||
# the release the tag names, so `/iam version` and the image tag are
|
||||
# the same string with no second identifier to drift.
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.tag }}
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
# The Dockerfile mounts this to rewrite github.com to an authenticated
|
||||
# fetch for the private hanzoai modules. Without it `go mod download`
|
||||
# fails on hanzoai/orm.
|
||||
@@ -159,10 +182,15 @@ jobs:
|
||||
# build-push-action can exit 0 before the manifest is resolvable at the
|
||||
# registry. Prove the image actually pulls, so a green run always means a
|
||||
# usable image rather than a future ImagePullBackOff.
|
||||
#
|
||||
# Only when something was published: a branch build pushes nothing, so
|
||||
# there is no manifest at the registry to resolve and asking for one
|
||||
# fails a run that did exactly what it should.
|
||||
- name: Verify the pushed image resolves
|
||||
if: steps.meta.outputs.push == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
img="ghcr.io/hanzoai/iam:${{ steps.meta.outputs.tag }}"
|
||||
img="${{ steps.meta.outputs.image }}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if docker manifest inspect "$img" >/dev/null 2>&1; then
|
||||
echo "$img is pullable"; exit 0
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is the build plane (.hanzo/workflows/image.yml cuts the image)
|
||||
# but development also lands on github.com/hanzoai/iam. This job is what carries
|
||||
# commits between them, and it is the ONLY one — the GitHub-side push nudge
|
||||
# (.github/workflows/sync.yaml) was deleted with it.
|
||||
#
|
||||
# WHY PULL, NOT PUSH. Four mechanisms could in principle sync this repo; three
|
||||
# provably cannot:
|
||||
# - org webhook -> git.hanzo.ai/v1/sync, and cron.update_mirrors: BOTH are
|
||||
# mirror-sync. This repo is mirror:false, and the forge rejects it outright:
|
||||
# POST /v1/repos/hanzoai/iam/mirror-sync -> 400 {"message":"Repository is
|
||||
# not a mirror"}. Those paths cover the ~2,300 mirror repos, never this one.
|
||||
# - GitHub Actions push: needs a forge-WRITE token (FORGE_TOKEN) inside
|
||||
# GitHub's secret store. It was never set here, so every run since the
|
||||
# workflow was tightened failed `FORGE_TOKEN is not set` — 8 red runs on
|
||||
# 2026-08-02 alone — while main drifted 2 commits / ~3h behind GitHub.
|
||||
#
|
||||
# So this repo had ZERO working sync paths, and image.yml never saw a commit.
|
||||
#
|
||||
# The pull needs no new secret: GH_PAT is already a git.hanzo.ai ORG secret for
|
||||
# hanzoai (created 2026-07-19), so it is in scope for every repo here. The only
|
||||
# credential is READ-only against GitHub, held in-cluster; the forge write is
|
||||
# done by the runner's own workflow token against the instance URL that
|
||||
# actions/checkout already uses. Nothing needs a forge-write key in GitHub.
|
||||
#
|
||||
# Fast-forward only: a divergence fails LOUDLY rather than force-pushing either
|
||||
# side.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Checkout forge main (full history for the ancestry check)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist the token-auth remote so the push below reuses it.
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzoai/iam
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_PAT:-}" ]; then
|
||||
echo "::error::GH_PAT is not set — refusing to sync silently."
|
||||
exit 1
|
||||
fi
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/iam.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"
|
||||
REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
echo "in sync at $LOCAL"
|
||||
exit 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does NOT trigger other
|
||||
# workflows (loop prevention) — so synced commits would never cut
|
||||
# an image. Dispatch the builder explicitly; a real ff means real
|
||||
# commits arrived, so bypassing its paths judgement is correct.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/image.yml/dispatches" \
|
||||
-d '{"ref":"main"}' \
|
||||
&& echo "image dispatched" || echo "image dispatch failed (non-fatal — next direct push will build)"
|
||||
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
|
||||
echo "forge is AHEAD of GitHub ($REMOTE ancestor of $LOCAL) — nothing to pull."
|
||||
echo "(GitHub catch-up is a separate concern; never force from here.)"
|
||||
else
|
||||
echo "::error::main DIVERGED between GitHub ($REMOTE) and forge ($LOCAL) — refusing to force. Reconcile manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Carry release tags, not just main.
|
||||
#
|
||||
# image.yml publishes ONLY for refs/tags/v* — its meta step sets push=true
|
||||
# there and push=false everywhere else, naming the result `unpublished`.
|
||||
# This job fetched and pushed `main` alone and dispatched with ref: main, so
|
||||
# a `v*` tag cut on GitHub reached neither the forge nor the builder, and the
|
||||
# dispatch it DID make could never publish. That is why iam.yaml already
|
||||
# recorded "v1.34.5 was tagged in git and never built", and why v1.33.32..37
|
||||
# have no images either. A release that builds nothing looks exactly like one
|
||||
# that shipped, which is what makes it expensive to notice.
|
||||
#
|
||||
# The workflow token deliberately does not trigger other workflows (loop
|
||||
# prevention), so pushing the tag is not enough: the build is dispatched
|
||||
# explicitly on the TAG ref, the only ref image.yml will publish.
|
||||
- name: Carry release tags to the forge and build them
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet --tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/iam.git" 'refs/tags/v*:refs/tags/v*' || true
|
||||
|
||||
# ONLY tags NEWER than the forge's highest — not every tag it lacks.
|
||||
#
|
||||
# "Every tag the forge lacks" is unreachable as an invariant and wedged
|
||||
# this job for days. The forge repo was created without history's tags,
|
||||
# so ~160 of them (v1.0.0 … v1.31.x) are permanently "unpushed"; the cap
|
||||
# below saw 160, exited 1 on EVERY run, and the tag step never reached a
|
||||
# real release. That is why v1.34.5 and v1.34.8 were tagged and never
|
||||
# built — starved behind ancient tags nobody wanted rebuilt.
|
||||
#
|
||||
# Anchoring on the forge's own highest tag makes the set converge: it is
|
||||
# empty in the steady state, and after a release it holds exactly the new
|
||||
# ones. Backfilling the history is deliberately NOT done here — pushing
|
||||
# those tags would fire image.yml once per tag, which is the tag storm the
|
||||
# cap exists to prevent.
|
||||
# ONE round trip for the forge's whole tag list, then compare locally.
|
||||
# Asking `git ls-remote` per tag is ~170 network calls against this repo's
|
||||
# tag count: it is what made the step take a minute-plus, and it is 170
|
||||
# chances for one transient failure to kill the job under `set -e`.
|
||||
git ls-remote --tags --refs origin 'refs/tags/v*' 2>/dev/null \
|
||||
| sed 's#.*refs/tags/##' | sort -V > /tmp/forge-tags || true
|
||||
high=$(tail -1 /tmp/forge-tags)
|
||||
echo "forge holds $(wc -l < /tmp/forge-tags | tr -d ' ') release tags; highest: ${high:-<none>}"
|
||||
new=""
|
||||
for t in $(git tag --list 'v*' | sort -V); do
|
||||
# `if !` rather than `cmd && continue`: a bare failing AND-list is
|
||||
# itself a failed statement, which `set -e` turns into an exit.
|
||||
if grep -qxF "$t" /tmp/forge-tags; then
|
||||
continue # already on the forge
|
||||
fi
|
||||
if [ -n "$high" ] && [ "$(printf '%s\n%s\n' "$high" "$t" | sort -V | tail -1)" = "$high" ]; then
|
||||
continue # older than the forge's highest — history, not a release
|
||||
fi
|
||||
new="$new $t"
|
||||
done
|
||||
new=$(echo $new)
|
||||
if [ -z "$new" ]; then echo "no unpushed release tags"; exit 0; fi
|
||||
count=$(echo "$new" | wc -w | tr -d ' ')
|
||||
# A cap, stated out loud. A tag storm has starved this CI before, and a
|
||||
# silent truncation would read as "everything built".
|
||||
if [ "$count" -gt 5 ]; then
|
||||
echo "::error::$count unpushed tags ($new) — refusing to dispatch that many builds at once. Push and build them deliberately."
|
||||
exit 1
|
||||
fi
|
||||
for t in $new; do
|
||||
echo "pushing and building $t"
|
||||
git push origin "refs/tags/$t"
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/image.yml/dispatches" \
|
||||
-d "{\"ref\":\"$t\"}" \
|
||||
&& echo " dispatched $t" || echo "::warning::dispatch failed for $t — tag is on the forge; build it by hand"
|
||||
done
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Hanzo IAM — proprietary identity service (zip + orm).
|
||||
# Hanzo IAM — identity service (zip + orm).
|
||||
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
|
||||
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
Hanzo IAM v2 — Proprietary Software License
|
||||
Hanzo IAM
|
||||
|
||||
Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
Copyright (c) 2024-2026 Hanzo AI, Inc.
|
||||
|
||||
This software and its source code (the "Software") are the confidential and
|
||||
proprietary property of Hanzo AI, Inc. ("Hanzo"). The Software is licensed,
|
||||
not sold, and only under an express written agreement signed by Hanzo.
|
||||
Licensed under either of
|
||||
|
||||
Except as granted by such an agreement, no license, right, or interest in the
|
||||
Software is conveyed. You may not use, copy, modify, merge, publish, distribute,
|
||||
sublicense, reverse engineer, or create derivative works of the Software, in
|
||||
whole or in part, by any means.
|
||||
* Apache License, Version 2.0 (LICENSE-APACHE or
|
||||
http://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL HANZO BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT, OR OTHERWISE, ARISING FROM OR IN CONNECTION WITH THE SOFTWARE.
|
||||
at your option.
|
||||
|
||||
This is a clean-room implementation. It contains no Apache-2.0 or other
|
||||
third-party licensed source code. Third-party dependencies are consumed
|
||||
under their own licenses as declared in go.mod.
|
||||
SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||||
dual licensed as above, without any additional terms or conditions.
|
||||
|
||||
Provenance: this tree is original work. It carries no Casdoor source and no
|
||||
other third-party licensed source code. The retired Casdoor-derived fork is
|
||||
github.com/hanzoai/iam-v1; its versions are retracted in go.mod (see
|
||||
TestCasdoorLineageRetracted). Third-party dependencies are consumed under their
|
||||
own licenses as declared in go.mod.
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Hanzo AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -2,9 +2,28 @@
|
||||
|
||||
Canonical **Hanzo IAM** service: identity & access for the Hanzo cloud —
|
||||
OpenID Connect / OAuth2 with PKCE, JWKS, UserInfo, SCIM 2.0, MFA/WebAuthn,
|
||||
social federation. The server behind the `@hanzo/iam` SDK. Proprietary,
|
||||
clean-room rewrite on the Hanzo stack (`zip` over `hanzoai/orm`) — no the legacy surface,
|
||||
Beego, or xorm. Retired the legacy surface fork = `hanzoai/iam-v1` (do not use).
|
||||
social federation. The server behind the `@hanzo/iam` SDK. A clean-room
|
||||
rewrite on the Hanzo stack (`zip` over `hanzoai/orm`) — no Casdoor,
|
||||
Beego, or xorm. The retired Casdoor fork is `hanzoai/iam-v1` (archived, do not use);
|
||||
its versions are retracted here — see `TestCasdoorLineageRetracted`.
|
||||
|
||||
## License — `MIT OR Apache-2.0`
|
||||
Dual-licensed at the user's option: `LICENSE-MIT` + `LICENSE-APACHE` (canonical
|
||||
texts, never edited), `LICENSE` declares the pair. HIP-0130 puts `iam` in the OSS
|
||||
core tier, so the previous "confidential and proprietary / All rights reserved"
|
||||
LICENSE contradicted both the HIP and the repo's own public visibility. Every Go
|
||||
file carries `// SPDX-License-Identifier: MIT OR Apache-2.0` instead of the old
|
||||
`All rights reserved` header; `go.mod` has no license field, and this repo ships
|
||||
no Cargo/npm/PyPI manifest, so the SPDX headers plus the three files are the
|
||||
whole declaration.
|
||||
|
||||
Relicensing was Hanzo's alone to do: the tree is original work, not a fork
|
||||
(`fork: false`, its root commit is its own, and no `v1.*` Casdoor tag is an
|
||||
ancestor of `main`). Note the Casdoor-lineage tags `v1.0.0`–`v1.31.37` are still
|
||||
present on this remote even though `go.mod` says they "now live at
|
||||
`hanzoai/iam-v1`" — anyone checking out one of those tags gets Apache-2.0
|
||||
Casdoor code under this repo's name. The retraction covers module resolution,
|
||||
not `git checkout`.
|
||||
|
||||
## Role in the model
|
||||
This is a `hanzoai/<product>` service (impl lives here, DRY — one place). It is
|
||||
@@ -55,7 +74,9 @@ a shared binary.
|
||||
## Endpoints (HIP-0111 — /v1 only, no /api, no vendor verbs)
|
||||
`/.well-known/openid-configuration` · `/v1/iam/.well-known/jwks` ·
|
||||
`/v1/iam/oauth/{authorize,token,introspect,revoke,userinfo,logout,callback}` ·
|
||||
`/v1/iam/scim/v2/Users`. PKCE `S256` always; `client_id` = `<org>-<app>`.
|
||||
`/v1/iam/oauth/device` + `/v1/iam/oauth/device/info` (RFC 8628; `info` names the
|
||||
client a pending `user_code` belongs to, session-gated, code in the BODY because
|
||||
a request line reaches access logs) · `/v1/iam/scim/v2/Users`. PKCE `S256` always; `client_id` = `<org>-<app>`.
|
||||
Brands set `serverUrl`: hanzo→iam.hanzo.ai, lux→lux.id, zoo→zoo.id,
|
||||
bootnode→id.bootno.de, pars→pars.id (white-label by domain).
|
||||
|
||||
@@ -224,8 +245,15 @@ for redaction. The secret is revealed ONCE, by `create`. `capFor("keys")` =
|
||||
manages.
|
||||
|
||||
`MintUserKey` writes a `schema.Key` ROW because that is the only thing the resolvers
|
||||
read. Stamping it on `schema.User.AccessKey` authenticated nobody AND overwrote the
|
||||
holder's working legacy `hk-`, locking them out with no path back through the UI.
|
||||
read. Stamping it on `schema.User.AccessKey` authenticated nobody — nothing resolves
|
||||
that field, and it is not a credential.
|
||||
|
||||
**Two key shapes, estate-wide.** `pk-` is publishable and `sk-` is secret; there is no
|
||||
third. `store.UserByAccessKey` resolves a live `sk-` (pinned to the key row's own
|
||||
tenant), refuses a `pk-` as `key_wrong_door` — a real credential at the wrong door —
|
||||
and answers `key_unknown` for everything else, which is what renders the actionable
|
||||
"mint a new one at cloud.hanzo.ai/keys". A value carrying any other prefix is not a
|
||||
key, so it takes that same unknown path rather than a branch of its own.
|
||||
|
||||
## Refresh — confidential is a property of the GRANT, and a lifetime must be SAID
|
||||
|
||||
@@ -321,6 +349,46 @@ issued to and a refresh token was being presented under a different id.
|
||||
- `internal/{oidc,routes}` — OAuth2/OIDC surface; `internal/{scim,mfa,webauthn,providers,sessions,tokens,cred,authz,certs,keys}`.
|
||||
- `internal/{users,organizations,applications,roles,permission,memberships}` — entities; `pkg/model`, `pkg/store`; `MIGRATION.md` (RFC surface + phases).
|
||||
|
||||
## CORS — two questions, and the edge answers a third
|
||||
|
||||
`internal/cors` decides two things about an `Origin`, and conflating them is a
|
||||
privilege escalation:
|
||||
|
||||
1. **May it read?** The DERIVED allowlist — any origin some application already
|
||||
registered a `redirect_uri` on. A tenant admin can write into this set, so it
|
||||
only ever grants reads of answers that carry no ambient authority.
|
||||
2. **May it send the SSO cookie and read the answer?** `IAM_SESSION_ORIGINS`, a
|
||||
comma-separated list of **exact** origins. Never a suffix, never derived from
|
||||
(1). A malformed entry **panics at route registration**, which is the one
|
||||
place both `iam serve` and the cloud binary that embeds IAM pass through.
|
||||
|
||||
The `[cookie]` paths are exactly the five sites `hanzoai/js-iam`
|
||||
`src/browser.ts` sends `credentials: "include"` to — `POST /v1/iam/login`,
|
||||
`GET /v1/iam/web3/nonce`, `POST /v1/iam/web3/verify`, `POST /v1/iam/oauth/revoke`,
|
||||
`POST /v1/iam/oauth/logout`. A browser DISCARDS a credentialed response that
|
||||
lacks `Access-Control-Allow-Credentials`, so withholding it on one of them
|
||||
withholds no privilege — it breaks the call. Only `POST /v1/iam/login` actually
|
||||
spends the cookie (the single-sign-on branch mints an authorization code from
|
||||
it); revoke, logout and both wallet legs never read or clear it, so the SDK's
|
||||
`credentials: "include"` there is inert and the SDK is where that gets fixed.
|
||||
**`logout` not ending the portal session is a real open defect**, not a CORS one.
|
||||
|
||||
`IAM_TRUSTED_ORIGIN_SUFFIXES` is a DIFFERENT list, read nowhere in this repo.
|
||||
Never wire it to question 2: the fleet serves `<slug>.hanzo.app` as
|
||||
customer-published sites, so a suffix read of it would name every customer page
|
||||
a first-party console.
|
||||
|
||||
**A proxy can override all of this.** Measured 2026-08-01: hitting the cluster
|
||||
ingress directly with `Host: iam.hanzo.ai` returns `server: zip`, `Vary: Origin`
|
||||
and no ACAO; the same request through Cloudflare returns
|
||||
`Access-Control-Allow-Credentials: true` plus the reflected origin. The
|
||||
`hanzo.ai` zone reflects a suffix set (`hanzo.ai`, `hanzo.app`, `hanzo.bot`,
|
||||
`lux.network`, `zoo.ngo`, `zoo.network`, `pars.ai`, `bootno.de`, `ad.nexus`) and
|
||||
the `hanzo.id` zone reflects **any** origin. `*.hanzo.ai` is SAME-SITE with
|
||||
`iam.hanzo.ai`, so `SameSite=Lax` does not withhold `hanzo_session` — that is the
|
||||
reachable path. No Go change closes it; the edge rule has to be narrowed, and
|
||||
this package must answer correctly FIRST or the narrowing breaks every login.
|
||||
|
||||
## OPEN P0 — self-service signup enrolls strangers in the staff tenant
|
||||
|
||||
`hanzo-console` / `hanzo-cloud` / `hanzo-gitea` / `hanzo-bot` carry
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Identity & access for the Hanzo cloud — OpenID Connect / OAuth2 with PKCE, standards only.**
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Hanzo IAM is the identity service behind every Hanzo sign-in: OpenID Connect
|
||||
discovery, the authorize + token endpoints (authorization code + PKCE, refresh,
|
||||
@@ -114,7 +114,7 @@ table.
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — see [LICENSE](./LICENSE). Confidential to Hanzo AI, Inc.
|
||||
Dual-licensed under [MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE) at your option, as the OSS core tier of HIP-0130.
|
||||
|
||||
## Hanzo — the Open AI Cloud
|
||||
|
||||
|
||||
+5
-5
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package feature is the seam enterprise capabilities plug into. A module
|
||||
// (hanzoiam/saml, hanzoiam/ldap, …) implements Feature and reads/writes the core's
|
||||
@@ -14,10 +15,9 @@
|
||||
// authz.Scope and authz.Can cover it without a module having to reimplement them:
|
||||
// SCIM is served there (internal/scim, at /v1/iam/scim/v2), never through this seam.
|
||||
//
|
||||
// A module gets NO authorization for free. The Guard is mounted on the prefixes
|
||||
// IAM owns (internal/routes.guardedPrefixes), not on the app, so a module that
|
||||
// registers outside them is unauthenticated — the one thing a Feature must get
|
||||
// right on its own.
|
||||
// A module gets NO authorization for free. IAM's Guard is anchored in IAM's own
|
||||
// subtree (internal/routes.Route), so a module that registers anywhere else is
|
||||
// unauthenticated — the one thing a Feature must get right on its own.
|
||||
package feature
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package feature_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -19,8 +19,8 @@ retract [v1.0.0, v1.31.37] // Casdoor lineage; moved to github.com/hanzoai/iam-v
|
||||
require (
|
||||
github.com/hanzoai/orm v0.6.16
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zap-proto/zip v1.18.22
|
||||
golang.org/x/crypto v0.53.0
|
||||
github.com/zap-proto/zip v1.24.2
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
// Migration-only: linked solely in `go build -tags migration` so `iam compare`
|
||||
@@ -35,9 +35,9 @@ require (
|
||||
github.com/alexedwards/argon2id v1.0.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518
|
||||
github.com/hanzoai/account v0.2.1
|
||||
github.com/luxfi/crypto v1.20.1
|
||||
github.com/luxfi/crypto v1.20.2
|
||||
github.com/luxwallet/connect/go v0.1.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/valyala/fasthttp v1.72.0
|
||||
@@ -48,6 +48,7 @@ require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
@@ -58,11 +59,12 @@ require (
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/hanzoai/builder v0.3.13 // indirect
|
||||
github.com/hanzoai/csqlite v0.1.0 // indirect
|
||||
github.com/hanzoai/dbx v1.17.2 // indirect
|
||||
github.com/hanzoai/sqlcipher v0.1.0 // indirect
|
||||
github.com/hanzoai/sqlite v0.3.2 // indirect
|
||||
github.com/hanzoai/sqlcipher v0.1.1 // indirect
|
||||
github.com/hanzoai/sqlite v0.5.0 // indirect
|
||||
github.com/hanzoai/xorm v1.4.4 // indirect
|
||||
github.com/hanzokv/go/v9 v9.22.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -71,24 +73,26 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/luxfi/accel v1.2.4 // indirect
|
||||
github.com/luxfi/cache v1.2.1 // indirect
|
||||
github.com/luxfi/codec v1.1.4 // indirect
|
||||
github.com/luxfi/container v0.0.4 // indirect
|
||||
github.com/luxfi/ids v1.2.10 // indirect
|
||||
github.com/luxfi/cache v1.3.1 // indirect
|
||||
github.com/luxfi/container v0.2.1 // indirect
|
||||
github.com/luxfi/ids v1.3.2 // indirect
|
||||
github.com/luxfi/log v1.4.3 // indirect
|
||||
github.com/luxfi/math v1.4.1 // indirect
|
||||
github.com/luxfi/math v1.5.1 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/metric v1.5.7 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/luxfi/metric v1.8.1 // indirect
|
||||
github.com/luxfi/mock v0.1.1 // indirect
|
||||
github.com/luxfi/zap v1.2.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/zap-proto/go v1.3.0 // indirect
|
||||
@@ -96,11 +100,16 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.51.0 // indirect
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
@@ -70,10 +72,14 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 h1:UBg1xk+oAsIVbFuGg6hdfAm7EvCv3EL80vFxJNsslqw=
|
||||
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
|
||||
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
|
||||
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
|
||||
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
|
||||
github.com/hanzoai/account v0.2.1 h1:OpODtK/N+qcUy83yUj6br+yTTBoItJWneDtOZ3NlyFU=
|
||||
github.com/hanzoai/account v0.2.1/go.mod h1:8OzIGRphAhlabOI74O4GoL3RM0y8mbUV0pQUKgXLjkw=
|
||||
github.com/hanzoai/builder v0.3.13 h1:tAOJ+0Q0xrrovk7lkvaZxuKZ4lqENIB6tE0Rr9+6Bo8=
|
||||
@@ -84,14 +90,16 @@ github.com/hanzoai/dbx v1.17.2 h1:EBADhGuOMxCsc4eHj5cJmtE9c7tSKaviyl8URx31NOQ=
|
||||
github.com/hanzoai/dbx v1.17.2/go.mod h1:u7f8kFoy1tS6YRzVNEurA/NlkRF9Uq9ZhDEqOchFtSM=
|
||||
github.com/hanzoai/orm v0.6.16 h1:w3UXH65huahNJ8RgC88ffUeicAbHoUpQW8oLuDCojK8=
|
||||
github.com/hanzoai/orm v0.6.16/go.mod h1:KpbP5UwQ8BBNGVM3tku9rgs7PADB+UG8fqh8Nol0X/s=
|
||||
github.com/hanzoai/sqlcipher v0.1.0 h1:V9gKG3ZltN2ZCteDrOnXWfOeEe/YDhhUm9AorQEAuBo=
|
||||
github.com/hanzoai/sqlcipher v0.1.0/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
|
||||
github.com/hanzoai/sqlite v0.3.2 h1:B/TRunlIDZECEypmr6rHeNyWf37YZXvPJHBDEFMKn/g=
|
||||
github.com/hanzoai/sqlite v0.3.2/go.mod h1:a3llsefKbu2Iq/0rJ1mlWCaU2t2cXh+aze85x+oW72k=
|
||||
github.com/hanzoai/sqlcipher v0.1.1 h1:GARjSiUEa1lwhd1/f87XRaujZBG5s1ZwxrZW2Es/ADI=
|
||||
github.com/hanzoai/sqlcipher v0.1.1/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
|
||||
github.com/hanzoai/sqlite v0.5.0 h1:1YydiyNAvL+WcXC1lUqZsUDaR4/7YkVb+wZm9qq9DSc=
|
||||
github.com/hanzoai/sqlite v0.5.0/go.mod h1:7hlAtZspL0Ggx/j0cSo6npPFtUeikvIxnMDb7yTaJD0=
|
||||
github.com/hanzoai/xorm v1.4.4 h1:2VRwh5BtOgbED+CAzHQ47sPZBgljBlKnJw1Ar6V6il0=
|
||||
github.com/hanzoai/xorm v1.4.4/go.mod h1:fn6acg0hHm5FKGKlUxFvXOTdvP2IXXRS1+NEjYG8Raw=
|
||||
github.com/hanzokv/go/v9 v9.22.0 h1:zD4fh0NLBuVa8njIrXUivJCijlratzS1Yf7Y/uD5T00=
|
||||
github.com/hanzokv/go/v9 v9.22.0/go.mod h1:GV+nw+jX60sIrJ7LBkmOQw2WASXzkMstt52blcwNw6w=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -110,32 +118,39 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
|
||||
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
|
||||
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
|
||||
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
|
||||
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
|
||||
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
|
||||
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||
github.com/luxfi/crypto v1.20.1 h1:d0/jW7vVVQZbeGJNVmtMKkrhjTM6BtqEOWH234iUghM=
|
||||
github.com/luxfi/crypto v1.20.1/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU=
|
||||
github.com/luxfi/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
|
||||
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
|
||||
github.com/luxfi/cache v1.3.1 h1:grQhi/B5GKypG7avDMeY143QTgFbfEvQICKNIh1Cw6U=
|
||||
github.com/luxfi/cache v1.3.1/go.mod h1:2MokdbeNUy/9O3mdREWkE6BiN7tRvePkXiKkcb+4M7g=
|
||||
github.com/luxfi/container v0.2.1 h1:MTnfKXzS5+oxV5jKZerdOxSA6iMPaQI9/FWGufizzaw=
|
||||
github.com/luxfi/container v0.2.1/go.mod h1:B+uM0wP0lGvt/SSK7QOEn/qBcsHzILVHlKikdCyzSgM=
|
||||
github.com/luxfi/crypto v1.20.2 h1:L81WEsU/hs2A76F5PWBusG0yU74QqkDdUqqgexWUxh4=
|
||||
github.com/luxfi/crypto v1.20.2/go.mod h1:qYHOM0lO4PRh7LEaObxFQUIMjmT1/paVm/WgZkobT1k=
|
||||
github.com/luxfi/ids v1.3.2 h1:c6Rft5kZB4XqiCtWaGH47bfhaNFm3FGRfhEzI01GVeI=
|
||||
github.com/luxfi/ids v1.3.2/go.mod h1:+5l8cYMbKpORJbQ2r98CYJo9TQATgUdnmzpYFZWMwwc=
|
||||
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
|
||||
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
|
||||
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
|
||||
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
|
||||
github.com/luxfi/math v1.5.1 h1:FDOY75e4vn/Xra1ij99xOS/9XdxQGCPP6HONHRkCwfg=
|
||||
github.com/luxfi/math v1.5.1/go.mod h1:3j9R24hVfPhrbvs45YSJP7jAyVNfwx/cj/+lAO8IGro=
|
||||
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
|
||||
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
|
||||
github.com/luxfi/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4=
|
||||
github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
|
||||
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
|
||||
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
|
||||
github.com/luxfi/metric v1.8.1 h1:v58GgPFAOLPVxSa/JiNLwqJQNEFHdWbXZV28piMXX4s=
|
||||
github.com/luxfi/metric v1.8.1/go.mod h1:R1OPAIeW4UBW3osK7j2r3/XPmczfNRFTXg4bnlemTuE=
|
||||
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxfi/pq v1.1.0 h1:ADplfUSyirLymSxs3Ix0HeDTyl5oswCNUpXJt/5vLY8=
|
||||
github.com/luxfi/pq v1.1.0/go.mod h1:KT5rG9ztpzIkT9QSnXK4WFqBBLzKCLjY7l1c/unBi8I=
|
||||
github.com/luxfi/zap v1.2.6 h1:NBpbm9Gib41Oi/XAkAZKQ3hb+xCafo7JsrUjw+bKiAc=
|
||||
github.com/luxfi/zap v1.2.6/go.mod h1:sTAe/AMMamoE85cVoe81+NbqHJkgvqS0LhY9ByHEmr0=
|
||||
github.com/luxwallet/connect/go v0.1.4 h1:Gmyl+MkrDxGI9jUjSzRt2yL/CL32apcLxVUdvoJdD7A=
|
||||
github.com/luxwallet/connect/go v0.1.4/go.mod h1:ReVK757g7VqTfcbUNg5SinpjBCzMgilEYm+Gux8tdmo=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
@@ -158,11 +173,15 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -179,8 +198,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a h1:1ur3QoCqvE5fl+nylMaIr9PVV1w343YRDtsy+Rwu7XI=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -199,18 +218,12 @@ github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
|
||||
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
|
||||
github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
|
||||
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zap-proto/zip v1.18.16 h1:7ZWwS07NE4qUJlJcvwViOgI4Ol29gtv1jPPFyy5CreA=
|
||||
github.com/zap-proto/zip v1.18.16/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.18.18 h1:oeDshCFqECBYDeIp9yFSP4wybo4oI8tzUt1L240N3v0=
|
||||
github.com/zap-proto/zip v1.18.18/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.18.19 h1:MAVs2bK1IGG0+rxJznBK4JbnhPh4J/Ldpoy9b+Xdx38=
|
||||
github.com/zap-proto/zip v1.18.19/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.18.20 h1:sP4BsrrhCpw4OVFRlO6a6fs0mjIsBeQyeNL+pJkBOVY=
|
||||
github.com/zap-proto/zip v1.18.20/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.18.21 h1:h90f1DReRU2yAigoAPjSM6BFX0gRbata2TDVNmaeKbM=
|
||||
github.com/zap-proto/zip v1.18.21/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.18.22 h1:lTQeI1+uIiZpCCWkV2N3ti6mPTfO1CI2oTj2b4DFLtk=
|
||||
github.com/zap-proto/zip v1.18.22/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.23.0 h1:R2uZV7SJouchj0BtSYk1Z6nTh3tSGaKGwV/XjuZqaSU=
|
||||
github.com/zap-proto/zip v1.23.0/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.24.1 h1:HF3Tm30bRBfFaAMkH0nsdrVku1XTvbqVSqhqWrOtf9k=
|
||||
github.com/zap-proto/zip v1.24.1/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zap-proto/zip v1.24.2 h1:kWKQeMzMf53PHTfHQvF+HrC0mEItcd1a++Ynuy97tqs=
|
||||
github.com/zap-proto/zip v1.24.2/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
@@ -223,10 +236,11 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -235,6 +249,8 @@ golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -251,12 +267,13 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -273,8 +290,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -287,10 +304,11 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
@@ -325,11 +343,31 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Canonical CI/CD config for hanzoai/iam — the one file both the hanzoai/ci
|
||||
# reusable (.hanzo/workflows/cicd.yml) and platform.hanzo.ai read.
|
||||
#
|
||||
# GATE ONLY. The image already has exactly one builder and it is deliberate:
|
||||
# .hanzo/workflows/image.yml, which tags by COMMIT SHA (a semver tag that gets
|
||||
# re-pushed leaves two digests behind one name, which is how platform's v4.4.5
|
||||
# came to mean two builds) and mounts the token `go mod download` needs for the
|
||||
# private hanzoai/orm + hanzoai/sqlite modules. That file's own header documents
|
||||
# why it is the ONLY file in this repo that builds an image. Declaring `images:`
|
||||
# here would make a second one, which is the exact failure it was written to end.
|
||||
#
|
||||
# The gate is the repo's own: `make test`. Two halves, both real —
|
||||
# * zipdoc -check in every directory that generates one: a codegen-freshness
|
||||
# refusal, so a handler doc comment that no longer matches its generated
|
||||
# zipdoc_gen.go fails the build instead of drifting silently.
|
||||
# * `go test ./... -race -count=1`: the whole suite (140 test files), under the
|
||||
# race detector, with caching off so a green means it ran here and now.
|
||||
# `go build ./...` runs first so a plain compile break fails in seconds rather
|
||||
# than after the full race build.
|
||||
#
|
||||
# Note on what is NOT here: no `-tags skipCi`. Files guarded `//go:build !skipCi`
|
||||
# vanish under that tag and `go test` then reports "[no tests to run]" and exits
|
||||
# 0 — a green over zero tests. This tree carries no such guard and this gate
|
||||
# passes no such tag; both halves of that have to stay true.
|
||||
test:
|
||||
- name: build
|
||||
run: |
|
||||
set -e
|
||||
go build ./...
|
||||
make test
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package applications is the Phase-1 typed CRUD surface for the `applications`
|
||||
// entity. Every operation is a zip typed handler (decode In -> run -> encode
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package applications
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package applications
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package auditlogs serves the IAM v2 CRUD surface for the `audit_logs` entity:
|
||||
// an append-only action record owner-scoped by (owner, name). Every operation
|
||||
@@ -147,6 +148,9 @@ func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, erro
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := refusePlatformAction(in.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch _, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("audit log already exists")
|
||||
@@ -181,6 +185,15 @@ func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, erro
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
// Neither the row you are correcting nor the correction may be a platform
|
||||
// record: the first would rewrite evidence, the second would forge it by
|
||||
// relabelling a row you own.
|
||||
if err := refusePlatformAction(log.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := refusePlatformAction(in.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apply(log, in)
|
||||
if err := log.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
@@ -198,12 +211,34 @@ func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := refusePlatformAction(log.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := log.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// refusePlatformAction rejects an action the PLATFORM writes about itself.
|
||||
//
|
||||
// This surface exists so your own systems can file their activity in the same
|
||||
// trail. It is not a way to author the platform's half of it. A consent grant, a
|
||||
// credential issued: those rows are the evidence that a thing happened, and
|
||||
// evidence anybody can write is not evidence — an org admin could mint a
|
||||
// "consent-training" row granting permission nobody gave, or delete the one
|
||||
// recording a refusal, and the trail would read exactly the same either way.
|
||||
//
|
||||
// So the platform's actions are reserved: not creatable here, and not alterable
|
||||
// or removable here once written. Retention expires them; nothing else does.
|
||||
func refusePlatformAction(action string) error {
|
||||
if schema.PlatformWritten(action) {
|
||||
return zip.ErrForbidden("the action " + action + " is written by the platform; " +
|
||||
"audit rows recording it cannot be created, corrected or deleted through this surface")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package auditlogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// This surface exists so a customer's own systems can file activity in the same
|
||||
// trail the platform writes to. Sharing one trail is the point — and it is also
|
||||
// the risk: the platform's rows are EVIDENCE (a consent answer, a credential
|
||||
// issued), and evidence anyone can author or erase is not evidence. So the
|
||||
// platform's own actions are reserved, and these tests are the four ways in.
|
||||
|
||||
func auditTestDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(t.TempDir(), "audittest.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// seedPlatformRow writes a row the way the platform writes one — directly, not
|
||||
// through this surface.
|
||||
func seedPlatformRow(t *testing.T, db orm.DB, name, action string) {
|
||||
t.Helper()
|
||||
log := orm.New[schema.AuditLog](db)
|
||||
log.Owner = "hanzo"
|
||||
log.Name = name
|
||||
log.Organization = "hanzo"
|
||||
log.User = "hanzo/alice"
|
||||
log.Action = action
|
||||
log.Object = `{"from":{"insights":true,"training":""},"to":{"insights":true,"training":"granted"}}`
|
||||
log.SetId(key("hanzo", name))
|
||||
if err := log.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Forging the grant. Without the gate an org admin posts a "consent-training"
|
||||
// row saying a member agreed, and nothing downstream can tell it from the row
|
||||
// the consent endpoint writes — same action, same shape, same trail.
|
||||
func TestCreateRefusesAPlatformAction(t *testing.T) {
|
||||
h := &Handler{db: auditTestDB(t)}
|
||||
for _, action := range []string{
|
||||
schema.ActionConsentTraining,
|
||||
schema.ActionIssueUserToken,
|
||||
schema.ActionMintUserKeys,
|
||||
schema.ActionRevokeUserKeys,
|
||||
schema.ActionTokenExchange,
|
||||
} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
_, err := h.Create(context.Background(), &Input{
|
||||
Owner: "hanzo", Name: "forged-" + action, Action: action,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("the audit CRUD minted a %q row", action)
|
||||
}
|
||||
if _, err := orm.Get[schema.AuditLog](h.db, key("hanzo", "forged-"+action)); err == nil {
|
||||
t.Fatal("the row was written anyway")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Erasing the refusal. A row recording that somebody declined is exactly the row
|
||||
// an org with an interest in training on their data would want gone.
|
||||
func TestDeleteRefusesAPlatformRow(t *testing.T) {
|
||||
db := auditTestDB(t)
|
||||
h := &Handler{db: db}
|
||||
seedPlatformRow(t, db, "evidence", schema.ActionConsentTraining)
|
||||
|
||||
if _, err := h.Delete(context.Background(), &Ref{Owner: "hanzo", Name: "evidence"}); err == nil {
|
||||
t.Fatal("a platform-written consent row was deleted through the audit CRUD")
|
||||
}
|
||||
if _, err := orm.Get[schema.AuditLog](db, key("hanzo", "evidence")); err != nil {
|
||||
t.Fatalf("the row is gone: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rewriting it, which is the quieter version of erasing it: flip the recorded
|
||||
// answer and the trail still has a row, just not a true one.
|
||||
func TestUpdateRefusesAPlatformRow(t *testing.T) {
|
||||
db := auditTestDB(t)
|
||||
h := &Handler{db: db}
|
||||
seedPlatformRow(t, db, "evidence", schema.ActionConsentTraining)
|
||||
|
||||
_, err := h.Update(context.Background(), &Input{
|
||||
Owner: "hanzo", Name: "evidence", Action: schema.ActionConsentTraining,
|
||||
Object: `{"from":{"training":"granted"},"to":{"training":"granted"}}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a platform-written consent row was rewritten through the audit CRUD")
|
||||
}
|
||||
stored, err := orm.Get[schema.AuditLog](db, key("hanzo", "evidence"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Object != `{"from":{"insights":true,"training":""},"to":{"insights":true,"training":"granted"}}` {
|
||||
t.Fatalf("the object was altered: %s", stored.Object)
|
||||
}
|
||||
}
|
||||
|
||||
// And the way in through the side door: write an ordinary row you are allowed to
|
||||
// write, then RELABEL it with the platform's action.
|
||||
func TestUpdateRefusesRelabellingIntoTheReservedNamespace(t *testing.T) {
|
||||
db := auditTestDB(t)
|
||||
h := &Handler{db: db}
|
||||
if _, err := h.Create(context.Background(), &Input{
|
||||
Owner: "hanzo", Name: "mine", Action: "my-own-thing",
|
||||
}); err != nil {
|
||||
t.Fatalf("an ordinary create was refused: %v", err)
|
||||
}
|
||||
|
||||
_, err := h.Update(context.Background(), &Input{
|
||||
Owner: "hanzo", Name: "mine", Action: schema.ActionConsentTraining,
|
||||
Object: `{"to":{"training":"granted"}}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("an ordinary row was relabelled into the platform's namespace")
|
||||
}
|
||||
stored, _ := orm.Get[schema.AuditLog](db, key("hanzo", "mine"))
|
||||
if stored == nil || stored.Action != "my-own-thing" {
|
||||
t.Fatalf("the action was changed: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must not confiscate the surface: a customer's own trail keeps working
|
||||
// end to end, including correction and deletion of their own rows.
|
||||
func TestAnOrdinaryRowIsStillFullyWritable(t *testing.T) {
|
||||
db := auditTestDB(t)
|
||||
h := &Handler{db: db}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := h.Create(ctx, &Input{Owner: "hanzo", Name: "r1", Action: "deploy", Object: "a"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := h.Update(ctx, &Input{Owner: "hanzo", Name: "r1", Action: "deploy", Object: "b"}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, err := h.Get(ctx, &Ref{Owner: "hanzo", Name: "r1"})
|
||||
if err != nil || got.Object != "b" {
|
||||
t.Fatalf("get: %v %+v", err, got)
|
||||
}
|
||||
if _, err := h.Delete(ctx, &Ref{Owner: "hanzo", Name: "r1"}); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An action that merely LOOKS like a platform one is ordinary. The reserved set
|
||||
// is exact, so the gate neither over-reaches nor can be slipped past by a near
|
||||
// miss that a later reader would mistake for the real thing.
|
||||
func TestTheReservedSetIsExact(t *testing.T) {
|
||||
for _, near := range []string{
|
||||
"consent", "consent-Training", "CONSENT-TRAINING", "consent-training ",
|
||||
" consent-training", "consent-training-x", "x-consent-training", "",
|
||||
} {
|
||||
if schema.PlatformWritten(near) {
|
||||
t.Fatalf("PlatformWritten(%q) = true — the gate over-reaches into customer actions", near)
|
||||
}
|
||||
}
|
||||
for _, exact := range []string{
|
||||
schema.ActionConsentTraining, schema.ActionIssueUserToken,
|
||||
schema.ActionMintUserKeys, schema.ActionRevokeUserKeys, schema.ActionTokenExchange,
|
||||
} {
|
||||
if !schema.PlatformWritten(exact) {
|
||||
t.Fatalf("PlatformWritten(%q) = false — a platform action is not reserved", exact)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-19
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package authz is the IAM v2 authorization seam in front of the Phase-1 entity
|
||||
// CRUD, which is otherwise unauthenticated — the door an attacker would walk
|
||||
@@ -420,18 +421,27 @@ func legacyVerb(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Guard is the AUTHENTICATION middleware. Route it via app.Use AFTER the public
|
||||
// group and BEFORE the authed routes: the public (pre-authentication) routes are
|
||||
// registered first, so a matched public route terminates fiber's middleware walk
|
||||
// and the Guard never runs on it — public vs gated is decided structurally, by
|
||||
// which group a route is registered on, not by an allow-list. Every route the
|
||||
// Guard does wrap — the typed CRUD handlers and the framework's /mcp and /openapi
|
||||
// surfaces alike — requires a valid bearer (401 otherwise) whose Principal is
|
||||
// attached to the request context for the authorization hook downstream. A read's
|
||||
// authorization target rides in the query string, so reads are authorized here; a
|
||||
// write's rides in the body, decoded once by the op and authorized at the op-invoke
|
||||
// seam (Authorize) on that exact decoded value — this middleware never re-parses a
|
||||
// write body, which is what let the old target extraction diverge from execution.
|
||||
// Guard is the AUTHENTICATION middleware. Mount it with Use on the GROUP that
|
||||
// holds the routes it gates — routes.Route registers IAM's authed surface on
|
||||
// such a group — never on the app itself. zip places middleware by depth: on the
|
||||
// app it becomes router middleware, a barrier in front of every request the
|
||||
// binary will ever serve, so IAM embedded beside other subsystems authenticated
|
||||
// THEIR routes against IAM's store and 401'd every valid request. Inside a
|
||||
// group it is composed into that group's own route chains and reaches nothing
|
||||
// else.
|
||||
//
|
||||
// Public vs gated stays structural — a public route is one registered on the
|
||||
// pre-authentication group instead of on the guarded one, never an entry in an
|
||||
// allow-list — and scoping now runs in the other direction too: a sibling
|
||||
// subsystem sharing the app is not IAM's to authenticate.
|
||||
//
|
||||
// Every route it wraps requires a valid bearer (401 otherwise) whose Principal
|
||||
// is attached to the request context for the authorization hook downstream. A
|
||||
// read's authorization target rides in the query string, so reads are authorized
|
||||
// here; a write's rides in the body, decoded once by the op and authorized at
|
||||
// the op-invoke seam (Authorize) on that exact decoded value — this middleware
|
||||
// never re-parses a write body, which is what let the old target extraction
|
||||
// diverge from execution.
|
||||
func Guard(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
// A CORS preflight carries no credentials BY DEFINITION — the browser
|
||||
@@ -468,10 +478,49 @@ func Guard(db orm.DB) zip.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize is the AUTHORIZATION hook, installed via app.Authorize so the
|
||||
// framework runs it at every typed op's invoke seam — after the request is
|
||||
// decoded into its typed In and validated, before the handler runs, for REST and
|
||||
// MCP alike. It authorizes the DECODED target: the exact (owner, name) the
|
||||
// mcpPath is where zip mounts the MCP door. zip exports SpecPath and DocsPath
|
||||
// but keeps this one unexported (zip/mcp.go defaultMCPPath), and IAM never moves
|
||||
// it — MCPConfig.Path is left at its default wherever IAM builds an app.
|
||||
const mcpPath = "/mcp"
|
||||
|
||||
// Control gates the framework's OWN projections: the MCP door, the OpenAPI
|
||||
// document and the docs UI. It is the SECOND mounting of the one Guard, and it
|
||||
// exists because those three addresses are not routes anybody registered.
|
||||
//
|
||||
// zip installs them at Build, directly onto the served app's router, with no
|
||||
// middleware and after every entry in the program (zip/build.go materialise:
|
||||
// "control routes are not entries at all"). A scoped seam therefore cannot reach
|
||||
// them — a group's middleware is composed into that group's own route chains,
|
||||
// and these are in no group — so the only seam that can is a depth-0 one.
|
||||
//
|
||||
// That is the whole reason authentication is mounted twice. Gating them matters
|
||||
// because the MCP door dispatches tools/call straight into the typed ops: it is
|
||||
// the same admin CRUD the REST surface exposes, reached by a different
|
||||
// transport, and the op-invoke hook alone does not close it (Authorize admits a
|
||||
// read whose decoded target is empty, on the REST-shaped assumption that the
|
||||
// Guard already ran). Unauthenticated, that combination lists users.
|
||||
//
|
||||
// Narrow by construction, and that is what keeps it from being the bug it
|
||||
// replaces: it is a depth-0 handler, so it is consulted on every request, but it
|
||||
// ACTS only on the three addresses the framework itself owns and hands every
|
||||
// other path straight on. A sibling subsystem's route is not one of them.
|
||||
func Control(db orm.DB) zip.Handler {
|
||||
guard := Guard(db) // one authentication decision, mounted twice, never copied
|
||||
return func(c *zip.Ctx) error {
|
||||
switch c.Path() {
|
||||
case mcpPath, zip.SpecPath, zip.DocsPath:
|
||||
return guard(c)
|
||||
}
|
||||
return c.Continue()
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize is the AUTHORIZATION hook. It is installed with Authorize on the
|
||||
// GROUP the typed ops register on — never on the app, which on a shared binary
|
||||
// would make IAM's rules the HOST's and refuse a sibling subsystem's ops 403 —
|
||||
// and the framework runs it at every typed op's invoke seam: after the request
|
||||
// is decoded into its typed In and validated, before the handler runs, for REST
|
||||
// and MCP alike. It authorizes the DECODED target: the exact (owner, name) the
|
||||
// handler will bind, read from the same struct the handler runs on, so the value
|
||||
// authorized cannot diverge from the value written.
|
||||
//
|
||||
@@ -481,9 +530,14 @@ func Guard(db orm.DB) zip.Handler {
|
||||
// arguments DO decode a target into In), is authorized against authorize().
|
||||
//
|
||||
// Every typed op is authed by construction — the public surface is raw handlers
|
||||
// in the pre-Guard group, none of which is a typed op — so this hook needs no
|
||||
// on the unguarded group, none of which is a typed op — so this hook needs no
|
||||
// public bypass: whenever it runs, the Guard has already run and attached a
|
||||
// principal (over REST, before the op; over MCP, on the gated /mcp route).
|
||||
// principal (over REST, on the guarded group the op registered on; over MCP, on
|
||||
// the /mcp route authz.Control gates). That second clause is why Control is not
|
||||
// optional. The owner == "" read admitted just below trusts the Guard to have
|
||||
// authorized the query-string target, and over MCP the arguments decode into In
|
||||
// rather than the query — so an ungated door would reach this line with no
|
||||
// principal, no decoded target, and an admission.
|
||||
func Authorize(ctx context.Context, op zip.Op, in any) error {
|
||||
owner, name := decodedTarget(in)
|
||||
if owner == "" && isRead(op.Method) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
// End-to-end authorization tests driven through the REAL registered router
|
||||
// (routes.Route, which installs authz.Guard after the public group, so gating is
|
||||
// structural — the public routes registered before it are never reached by it).
|
||||
// (routes.Route, which installs authz.Guard on the AUTHED group, so gating is
|
||||
// structural — the public routes, registered on a group that has no Guard, are
|
||||
// never reached by it).
|
||||
// Every case is a HTTP request
|
||||
// a client could send: a status code is the whole contract. Tokens are genuine
|
||||
// RS256 JWTs signed by the seeded admin signing cert, so they pass the exact
|
||||
@@ -142,7 +144,9 @@ func newHarness(t *testing.T) *harness {
|
||||
// side-door tests drive the ACTUAL routes — the same surface a served app
|
||||
// exposes — not a route that never got registered. MCP is left ENABLED here
|
||||
// (unlike prod) so the tests prove the guard, not a disabled feature, closes it.
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
@@ -69,7 +70,7 @@ func TestSuperIsAdminOrgOnly(t *testing.T) {
|
||||
|
||||
// Public vs gated is no longer a path allow-list this package owns — it is
|
||||
// STRUCTURAL, decided by which group a route is registered on in routes.Route
|
||||
// (the public group before the Guard, everything else after it). The boundary is
|
||||
// (the public group holds no Guard, the authed group holds it). The boundary is
|
||||
// therefore proven end-to-end over the real registered router: TestPublicRoutesNeedNoBearer
|
||||
// (public routes reachable without a bearer), TestUnauthenticatedWriteIs401 /
|
||||
// TestCrossOrgWriteIs403 (authed routes gated), and TestFrameworkSideDoorsAreGated
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
@@ -58,7 +59,7 @@ var (
|
||||
// is additionally tenant-bound by BoundToOrg.
|
||||
CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}
|
||||
|
||||
// CapKeyResolve gates resolving an opaque SECRET API key (hk-/sk-) to its owning
|
||||
// CapKeyResolve gates resolving an opaque SECRET API key (sk-) to its owning
|
||||
// principal via get-user?accessKey. It is a CREDENTIAL-DISCLOSURE boundary: the
|
||||
// caller presents a secret key and learns WHO it authenticates, so it must never
|
||||
// be an arbitrary authenticated caller. A public pk- is NOT resolved here: it is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
|
||||
+274
-99
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package bootstrap serves the operator-driven service-account provisioning
|
||||
// endpoints — `POST /v1/iam/admin/{applications,users}/upsert`. The Hanzo K8s
|
||||
@@ -13,13 +14,22 @@
|
||||
// token is system-level (bypasses the org-membership gate), so these routes live in
|
||||
// the PUBLIC group (before the Guard) and self-authenticate here. An unset token
|
||||
// fails closed: no service token configured → no bootstrap.
|
||||
//
|
||||
// Both are TYPED ops, so the credential is DECLARED — `header:"Authorization"` on
|
||||
// the input — rather than read out of a request the op cannot see. That is what
|
||||
// makes them ops at all: a fact no projection can read is not a fact the API has,
|
||||
// and the document, the tool schema and the command now all name the header the
|
||||
// call needs. It carries `json:"-"`, so the body and the query string cannot
|
||||
// supply it; a transport with no headers (MCP, the call plane) presents nothing
|
||||
// and is refused, which is the same fail-closed answer an unset token gets.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,19 +43,122 @@ import (
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the bootstrap upsert endpoints on the PUBLIC group r (they
|
||||
// self-authenticate via the service token, not a bearer principal).
|
||||
func Route(r zip.Router, db orm.DB) {
|
||||
r.Post("/v1/iam/admin/applications/upsert", upsertApplication(db))
|
||||
r.Post("/v1/iam/admin/users/upsert", upsertUser(db))
|
||||
//
|
||||
// r is the CONCRETE *zip.App a group already is: zipdoc resolves an op's path
|
||||
// prefix STATICALLY and cannot see through a zip.Router parameter, so a typed op
|
||||
// registered on one would have its doc comment filed under the wrong path and
|
||||
// dropped from both the document and the MCP tool. The prefix is empty either
|
||||
// way; nothing about the mount changes.
|
||||
//
|
||||
// Every status each op can answer is DECLARED, because zip refuses one that is
|
||||
// not — and because the document publishes exactly this set, so a generated
|
||||
// client has a branch for each. These two answer their refusals in their own
|
||||
// envelope (see reply), which is what a declared non-2xx is for.
|
||||
func Route(r *zip.App, db orm.DB) {
|
||||
zip.Post[registration, reply](r, "/v1/iam/admin/applications/upsert", upsertApplication(db),
|
||||
zip.WithOperationID("upsertApplication"),
|
||||
zip.WithStatus(200, 400, 401, 500),
|
||||
zip.WithTags("bootstrap"))
|
||||
|
||||
zip.Post[person, reply](r, "/v1/iam/admin/users/upsert", upsertUser(db),
|
||||
zip.WithOperationID("upsertUser"),
|
||||
zip.WithStatus(200, 400, 401, 500),
|
||||
zip.WithTags("bootstrap"))
|
||||
}
|
||||
|
||||
func unauthorized(c *zip.Ctx) error {
|
||||
return c.JSON(401, map[string]any{"status": "error", "msg": "a valid service token is required"})
|
||||
// reply is what both upserts answer, and the STATUS it rides on — this surface's
|
||||
// envelope as a VALUE, because a typed op returns its answer instead of writing
|
||||
// one. It is NOT httpx.Answer: these two predate that envelope and say `action`
|
||||
// (created or updated) where it says `code`, and carry no `data` at all on a
|
||||
// refusal. The operator parses this shape, so it is the shape that stays.
|
||||
//
|
||||
// The fields are in alphabetical order deliberately. Each of these bodies used to
|
||||
// be a map[string]any, encoding/json sorts a map's keys, and the wire may not move
|
||||
// under an operator that is already parsing it — so the struct emits the same
|
||||
// bytes in the same order.
|
||||
type reply struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Status string `json:"status"`
|
||||
|
||||
code int
|
||||
}
|
||||
|
||||
// appUpsertReq is the operator's application upsert body (operator-core UpsertRequest).
|
||||
type appUpsertReq struct {
|
||||
// StatusCode is [zip.StatusCoder]: the status this answer rides on. Zero means
|
||||
// the answer never named one, and 200 is what an unnamed answer has always been.
|
||||
func (r *reply) StatusCode() int {
|
||||
if r.code == 0 {
|
||||
return 200
|
||||
}
|
||||
return r.code
|
||||
}
|
||||
|
||||
// done is the 200 {status:"ok", action, data} answer — created or updated, and
|
||||
// what the upsert left behind.
|
||||
func done(action string, data any) *reply {
|
||||
return &reply{Action: action, Data: data, Status: "ok", code: 200}
|
||||
}
|
||||
|
||||
// refuse is the {status:"error", msg} answer under the status that matches it.
|
||||
// ONE function writes a refusal here; every one below names its status.
|
||||
//
|
||||
// It returns a VALUE rather than an error, and that is the whole contract: a
|
||||
// non-nil error renders zip's own {status,error} envelope, which is not what this
|
||||
// surface has ever answered.
|
||||
func refuse(status int, msg string) *reply {
|
||||
return &reply{Msg: msg, Status: "error", code: status}
|
||||
}
|
||||
|
||||
// credential is what an application upsert answers with: the registration as it
|
||||
// now stands, including the client secret — the operator is the caller, and this
|
||||
// is where it learns a secret it did not send. Alphabetical, per reply.
|
||||
type credential struct {
|
||||
ClientId string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
}
|
||||
|
||||
// account is what a user upsert answers with: the natural key of the row it
|
||||
// created or updated — the name as STORED, which the username rule may have
|
||||
// rewritten. Alphabetical, per reply.
|
||||
type account struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// decoded is what happened when the request body was read, carried on the input
|
||||
// because the handler is the only thing that may answer for it.
|
||||
//
|
||||
// zip renders a decode failure as its own {status,error} envelope and skips the
|
||||
// decoder entirely when there is no body — so an op that does neither has to learn
|
||||
// both facts itself. Unexported, so it is on no wire and in no schema.
|
||||
type decoded struct {
|
||||
sent bool
|
||||
err error
|
||||
}
|
||||
|
||||
// check is the refusal a body earns before a handler looks at it, or nil when it
|
||||
// arrived and parsed. The two sentences are the ones this surface has always
|
||||
// answered.
|
||||
func (d decoded) check() *reply {
|
||||
switch {
|
||||
case !d.sent:
|
||||
return refuse(400, "invalid body: empty request body")
|
||||
case d.err != nil:
|
||||
return refuse(400, "invalid body: "+d.err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registration is the application an operator declares (operator-core's
|
||||
// UpsertRequest), plus the service credential it presents.
|
||||
type registration struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
ClientId string `json:"clientId"`
|
||||
@@ -88,6 +201,27 @@ type appUpsertReq struct {
|
||||
// the default. Nil means "not stated, leave it".
|
||||
ExpireInHours *float64 `json:"expireInHours"`
|
||||
RefreshExpireInHours *float64 `json:"refreshExpireInHours"`
|
||||
// Auth is the `Authorization: Bearer <token>` header, the unified service
|
||||
// token this surface authenticates on. `json:"-"` keeps it off the body and
|
||||
// out of the query string, so the header is the only way to present it.
|
||||
Auth string `json:"-" header:"Authorization"`
|
||||
|
||||
decoded
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the body and RECORDS the outcome instead of failing on it,
|
||||
// so the handler stays the only thing that answers — see decoded.
|
||||
//
|
||||
// `body` is the same fields with none of the methods, which is what keeps this
|
||||
// from calling itself. It is also what a mismatched field is reported against, so
|
||||
// the message names the body rather than a Go type the caller has never heard of.
|
||||
func (r *registration) UnmarshalJSON(b []byte) error {
|
||||
type body registration
|
||||
var v body
|
||||
err := json.Unmarshal(b, &v)
|
||||
*r = registration(v)
|
||||
r.decoded = decoded{sent: true, err: err}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertApplication creates an application or updates it in place, so a
|
||||
@@ -97,91 +231,104 @@ type appUpsertReq struct {
|
||||
// It says which of the two it did. Leave the client secret out and the existing
|
||||
// one is kept — so re-running your deployment does not rotate a credential your
|
||||
// running services are holding.
|
||||
func upsertApplication(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !httpx.ServiceTokenAuth(c) {
|
||||
return unauthorized(c)
|
||||
func upsertApplication(db orm.DB) zip.TypedHandler[registration, reply] {
|
||||
return func(ctx context.Context, in *registration) (*reply, error) {
|
||||
if !httpx.ServiceAuth(in.Auth) {
|
||||
return refuse(401, "a valid service token is required"), nil
|
||||
}
|
||||
ctx := c.Context()
|
||||
var req appUpsertReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body: "+err.Error()))
|
||||
if bad := in.check(); bad != nil {
|
||||
return bad, nil
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
return c.JSON(400, errResp("name is required"))
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
return refuse(400, "name is required"), nil
|
||||
}
|
||||
|
||||
existing, err := store.GetApplicationByName(ctx, db, "admin", req.Name)
|
||||
existing, err := store.GetApplicationByName(ctx, db, "admin", in.Name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
var existingSecret string
|
||||
if existing != nil {
|
||||
existingSecret = existing.ClientSecret
|
||||
}
|
||||
req.ClientSecret = resolveSecret(req.Public, req.ClientSecret, existing != nil, existingSecret)
|
||||
if req.ClientId == "" {
|
||||
req.ClientId = req.Name // <org>-<app> convention: clientId == name
|
||||
in.ClientSecret = resolveSecret(in.Public, in.ClientSecret, existing != nil, existingSecret)
|
||||
if in.ClientId == "" {
|
||||
in.ClientId = in.Name // <org>-<app> convention: clientId == name
|
||||
}
|
||||
|
||||
action := "created"
|
||||
if existing != nil {
|
||||
action = "updated"
|
||||
existing.ClientId = req.ClientId
|
||||
existing.ClientSecret = req.ClientSecret
|
||||
existing.Organization = pick(req.Organization, existing.Organization)
|
||||
if req.DisplayName != "" {
|
||||
existing.DisplayName = req.DisplayName
|
||||
existing.ClientId = in.ClientId
|
||||
existing.ClientSecret = in.ClientSecret
|
||||
existing.Organization = pick(in.Organization, existing.Organization)
|
||||
if in.DisplayName != "" {
|
||||
existing.DisplayName = in.DisplayName
|
||||
}
|
||||
if len(req.GrantTypes) > 0 {
|
||||
existing.GrantTypes = req.GrantTypes
|
||||
if len(in.GrantTypes) > 0 {
|
||||
existing.GrantTypes = in.GrantTypes
|
||||
}
|
||||
if len(req.RedirectUris) > 0 {
|
||||
existing.RedirectUris = req.RedirectUris
|
||||
if len(in.RedirectUris) > 0 {
|
||||
existing.RedirectUris = in.RedirectUris
|
||||
}
|
||||
if req.Cert != "" {
|
||||
existing.Cert = req.Cert
|
||||
if in.Cert != "" {
|
||||
existing.Cert = in.Cert
|
||||
}
|
||||
if req.IsShared != nil {
|
||||
existing.IsShared = *req.IsShared
|
||||
if in.IsShared != nil {
|
||||
existing.IsShared = *in.IsShared
|
||||
}
|
||||
existing.ExpireInHours = ttl(req.ExpireInHours, existing.ExpireInHours)
|
||||
existing.RefreshExpireInHours = ttl(req.RefreshExpireInHours, existing.RefreshExpireInHours)
|
||||
existing.ExpireInHours = ttl(in.ExpireInHours, existing.ExpireInHours)
|
||||
existing.RefreshExpireInHours = ttl(in.RefreshExpireInHours, existing.RefreshExpireInHours)
|
||||
existing.EnablePassword = true
|
||||
if err := existing.UpdateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
} else {
|
||||
// A new application must NAME a signing cert, or it is not a
|
||||
// registration — it is a login that fails after the user has already
|
||||
// authenticated. Resolved here, where "brand new" is known, rather
|
||||
// than left to be discovered at the token endpoint.
|
||||
//
|
||||
// The cert ROW is deliberately not required to exist yet: an app that
|
||||
// records `cert-hanzo` signs correctly the moment that cert does,
|
||||
// whereas demanding it up front would order application creation
|
||||
// behind cert seeding and break a first-boot reconcile that has not
|
||||
// reached the certs. The name is the durable fact; its resolution is
|
||||
// the token endpoint's job.
|
||||
if in.Cert = resolveCert(in.Cert, in.Organization); in.Cert == "" {
|
||||
return refuse(400, fmt.Sprintf(
|
||||
"application %q would have no signing cert and no organization to "+
|
||||
"derive one from, so it could never issue a token: state `cert`",
|
||||
in.Name)), nil
|
||||
}
|
||||
a := orm.New[schema.Application](db)
|
||||
model := a.Model
|
||||
a.Owner, a.Name = "admin", req.Name
|
||||
a.ClientId, a.ClientSecret = req.ClientId, req.ClientSecret
|
||||
a.Organization, a.DisplayName = req.Organization, pick(req.DisplayName, req.Name)
|
||||
a.GrantTypes, a.RedirectUris, a.Cert = req.GrantTypes, req.RedirectUris, req.Cert
|
||||
a.Owner, a.Name = "admin", in.Name
|
||||
a.ClientId, a.ClientSecret = in.ClientId, in.ClientSecret
|
||||
a.Organization, a.DisplayName = in.Organization, pick(in.DisplayName, in.Name)
|
||||
a.GrantTypes, a.RedirectUris, a.Cert = in.GrantTypes, in.RedirectUris, in.Cert
|
||||
a.EnablePassword = true
|
||||
a.ExpireInHours = ttl(req.ExpireInHours, schema.DefaultExpireInHours)
|
||||
a.RefreshExpireInHours = ttl(req.RefreshExpireInHours, 0)
|
||||
a.ExpireInHours = ttl(in.ExpireInHours, schema.DefaultExpireInHours)
|
||||
a.RefreshExpireInHours = ttl(in.RefreshExpireInHours, 0)
|
||||
// A new app is single-tenant unless it says otherwise — fail closed.
|
||||
a.IsShared = req.IsShared != nil && *req.IsShared
|
||||
a.IsShared = in.IsShared != nil && *in.IsShared
|
||||
a.Model = model
|
||||
a.SetId("admin/" + req.Name)
|
||||
a.SetId("admin/" + in.Name)
|
||||
if err := a.CreateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok", "action": action,
|
||||
"data": map[string]any{
|
||||
"name": req.Name, "organization": req.Organization,
|
||||
"clientId": req.ClientId, "clientSecret": req.ClientSecret,
|
||||
},
|
||||
})
|
||||
return done(action, &credential{
|
||||
ClientId: in.ClientId, ClientSecret: in.ClientSecret,
|
||||
Name: in.Name, Organization: in.Organization,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
// userUpsertReq is the operator's user upsert body.
|
||||
type userUpsertReq struct {
|
||||
// person is the user an operator declares, plus the service credential it
|
||||
// presents.
|
||||
type person struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@@ -190,6 +337,20 @@ type userUpsertReq struct {
|
||||
Password string `json:"password"`
|
||||
PasswordType string `json:"passwordType"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
// Auth is the `Authorization: Bearer <token>` header — see registration.Auth.
|
||||
Auth string `json:"-" header:"Authorization"`
|
||||
|
||||
decoded
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the body and RECORDS the outcome — see registration's.
|
||||
func (p *person) UnmarshalJSON(b []byte) error {
|
||||
type body person
|
||||
var v body
|
||||
err := json.Unmarshal(b, &v)
|
||||
*p = person(v)
|
||||
p.decoded = decoded{sent: true, err: err}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertUser creates a person or updates them in place, so a deployment can
|
||||
@@ -197,47 +358,45 @@ type userUpsertReq struct {
|
||||
//
|
||||
// Passwords are hashed before they are stored. Leave the password out and their
|
||||
// current one is kept, so a redeploy never locks somebody out.
|
||||
func upsertUser(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !httpx.ServiceTokenAuth(c) {
|
||||
return unauthorized(c)
|
||||
func upsertUser(db orm.DB) zip.TypedHandler[person, reply] {
|
||||
return func(ctx context.Context, in *person) (*reply, error) {
|
||||
if !httpx.ServiceAuth(in.Auth) {
|
||||
return refuse(401, "a valid service token is required"), nil
|
||||
}
|
||||
ctx := c.Context()
|
||||
var req userUpsertReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body: "+err.Error()))
|
||||
if bad := in.check(); bad != nil {
|
||||
return bad, nil
|
||||
}
|
||||
req.Owner, req.Name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
|
||||
if req.Owner == "" || req.Name == "" {
|
||||
return c.JSON(400, errResp("owner and name are required"))
|
||||
in.Owner, in.Name = strings.TrimSpace(in.Owner), strings.TrimSpace(in.Name)
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return refuse(400, "owner and name are required"), nil
|
||||
}
|
||||
|
||||
var hash string
|
||||
if req.Password != "" {
|
||||
h, err := cred.Hash(req.Password)
|
||||
if in.Password != "" {
|
||||
h, err := cred.Hash(in.Password)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
|
||||
existing, err := store.GetUserByName(ctx, db, req.Owner, req.Name)
|
||||
existing, err := store.GetUserByName(ctx, db, in.Owner, in.Name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
action := "created"
|
||||
if existing != nil {
|
||||
action = "updated"
|
||||
existing.DisplayName = pick(req.DisplayName, existing.DisplayName)
|
||||
existing.Email = pick(req.Email, existing.Email)
|
||||
existing.Phone = pick(req.Phone, existing.Phone)
|
||||
existing.IsAdmin = req.IsAdmin
|
||||
existing.DisplayName = pick(in.DisplayName, existing.DisplayName)
|
||||
existing.Email = pick(in.Email, existing.Email)
|
||||
existing.Phone = pick(in.Phone, existing.Phone)
|
||||
existing.IsAdmin = in.IsAdmin
|
||||
if hash != "" {
|
||||
existing.PasswordHash, existing.PasswordType, existing.PasswordSalt = hash, cred.TypeArgon2id, ""
|
||||
}
|
||||
existing.UpdatedTime = now()
|
||||
if err := existing.UpdateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
} else {
|
||||
// A new row obeys THE username rule; an existing one is found above and
|
||||
@@ -245,29 +404,26 @@ func upsertUser(db orm.DB) zip.Handler {
|
||||
// happened to touch it. This path writes through orm directly rather than
|
||||
// users.Create (it seeds the first admin, before any principal exists), so
|
||||
// it states the rule itself — the one place that has to.
|
||||
name, err := schema.Username(req.Name)
|
||||
name, err := schema.Username(in.Name)
|
||||
if err != nil {
|
||||
return c.JSON(400, errResp(err.Error()))
|
||||
return refuse(400, err.Error()), nil
|
||||
}
|
||||
req.Name = name // the id and the response report what was STORED
|
||||
in.Name = name // the id and the response report what was STORED
|
||||
u := orm.New[schema.User](db)
|
||||
model := u.Model
|
||||
u.Owner, u.Name = req.Owner, name
|
||||
u.DisplayName, u.Email, u.Phone, u.IsAdmin = req.DisplayName, req.Email, req.Phone, req.IsAdmin
|
||||
u.Owner, u.Name = in.Owner, name
|
||||
u.DisplayName, u.Email, u.Phone, u.IsAdmin = in.DisplayName, in.Email, in.Phone, in.IsAdmin
|
||||
if hash != "" {
|
||||
u.PasswordHash, u.PasswordType = hash, cred.TypeArgon2id
|
||||
}
|
||||
u.CreatedTime, u.UpdatedTime = now(), now()
|
||||
u.Model = model
|
||||
u.SetId(req.Owner + "/" + req.Name)
|
||||
u.SetId(in.Owner + "/" + in.Name)
|
||||
if err := u.CreateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok", "action": action,
|
||||
"data": map[string]any{"owner": req.Owner, "name": req.Name},
|
||||
})
|
||||
return done(action, &account{Name: in.Name, Owner: in.Owner}), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,17 +454,36 @@ func resolveSecret(public bool, requested string, hasExisting bool, existing str
|
||||
}
|
||||
}
|
||||
|
||||
// decode reads the raw JSON body (content-type independent) into v.
|
||||
func decode(c *zip.Ctx, v any) error {
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return errors.New("empty request body")
|
||||
// resolveCert decides the signing cert a NEW application is created with. Same
|
||||
// shape as resolveSecret, and split out for the same reason: it is the ONE place
|
||||
// the rule lives.
|
||||
//
|
||||
// It is not cosmetic, and it fails LATE if it is wrong. issueTokens resolves
|
||||
// app.Cert to sign, so an application created without one authenticates the user,
|
||||
// mints an authorization code, redeems it — and only then discovers it has
|
||||
// nothing to sign with, answering the token exchange `500 server_error`. From the
|
||||
// browser that is indistinguishable from an outage, and it is exactly the state
|
||||
// `hanzo-tabs` shipped in.
|
||||
//
|
||||
// - requested -> honour it.
|
||||
// - otherwise -> the organization's own cert. Every application here already
|
||||
// follows one signing identity per org (`cert-hanzo`, `cert-lux`,
|
||||
// `cert-adnexus`…), so the default is that convention, not an invention.
|
||||
//
|
||||
// The caller VERIFIES the result resolves to a real cert and refuses the
|
||||
// registration otherwise. Only the create path consults this: on an existing
|
||||
// application a blank request means "not stated", never "clear it", which is what
|
||||
// lets a document add the field without rotating anything.
|
||||
func resolveCert(requested, org string) string {
|
||||
if r := strings.TrimSpace(requested); r != "" {
|
||||
return r
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
if org = strings.TrimSpace(org); org != "" {
|
||||
return "cert-" + org
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
|
||||
|
||||
// ttl applies an optionally-declared token lifetime: nil PRESERVES cur (an
|
||||
// omitted field never resets a deliberate lifetime on a steady-state reconcile),
|
||||
// a stated value wins — including an explicit 0, which is how a document says
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
@@ -39,7 +40,9 @@ func boot(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
app := zip.New(zip.Config{AppName: "bootstrap-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return app, db
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package bootstrap
|
||||
|
||||
import "testing"
|
||||
|
||||
// A new application must be created able to SIGN. issueTokens resolves app.Cert,
|
||||
// so a registration without one authenticates the user, mints a code, redeems it,
|
||||
// and only then answers `500 server_error` — a login that fails after the user has
|
||||
// already done everything right, and looks from the browser like an outage.
|
||||
//
|
||||
// `hanzo-tabs` shipped in exactly that state: registered by an upsert that never
|
||||
// mentioned a cert, and every sign-in died at the token exchange.
|
||||
func TestResolveCert_ANewApplicationCanAlwaysSign(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
requested string
|
||||
org string
|
||||
want string
|
||||
}{
|
||||
{name: "explicit wins", requested: "cert-special", org: "hanzo", want: "cert-special"},
|
||||
{name: "explicit wins with no org", requested: "cert-special", want: "cert-special"},
|
||||
{name: "derived from the organization", org: "hanzo", want: "cert-hanzo"},
|
||||
{name: "derived for any brand", org: "lux", want: "cert-lux"},
|
||||
{name: "blank is not a cert", requested: " ", org: "zoo", want: "cert-zoo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolveCert(tc.requested, tc.org); got != tc.want {
|
||||
t.Errorf("resolveCert(%q, %q) = %q, want %q", tc.requested, tc.org, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing to derive from. The caller must REFUSE rather than create a client
|
||||
// that can never mint a token — an empty result is what triggers that, so it
|
||||
// has to stay empty rather than become a plausible-looking guess.
|
||||
if got := resolveCert("", ""); got != "" {
|
||||
t.Errorf("resolveCert with nothing to go on = %q, want empty so the caller refuses", got)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/bootstrap"
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// The operator parses these bodies BY HAND, so the BYTES are the contract: the
|
||||
// status, the key order, and the presence or absence of every key. Each body was
|
||||
// a map[string]any once, encoding/json sorts a map's keys, and the structs that
|
||||
// replaced the maps emit the same bytes in the same order. This pins that — a
|
||||
// field reordered, an omitempty dropped, or a refusal that starts rendering zip's
|
||||
// own {status,error} envelope all fail here.
|
||||
//
|
||||
// It drives bootstrap.Route on its own app rather than the whole route table:
|
||||
// this is the surface under test, and bootstrap_test.go already proves the two
|
||||
// addresses are mounted in the table.
|
||||
|
||||
// wire is one app serving only the bootstrap surface, over its own store.
|
||||
func wire(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
|
||||
_ = schema.Kinds()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(t.TempDir(), "wire.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
app := zip.New(zip.Config{AppName: "bootstrap-wire", DisableStartupMessage: true})
|
||||
bootstrap.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// raw is the answer as it reaches the wire: the status and the exact bytes.
|
||||
func raw(t *testing.T, app *zip.App, path, auth, body string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
resp, err := testhttp.Do(app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func TestWire(t *testing.T) {
|
||||
const (
|
||||
apps = "/v1/iam/admin/applications/upsert"
|
||||
users = "/v1/iam/admin/users/upsert"
|
||||
nope = `{"msg":"a valid service token is required","status":"error"}`
|
||||
)
|
||||
bearer := "Bearer " + svcToken
|
||||
app := wire(t)
|
||||
|
||||
// Ordered: the created/updated pairs are two requests against one store.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
auth string
|
||||
body string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
// The service token, and the ONLY way to present it. A body field and a
|
||||
// query param are both refused, which is what `json:"-"` on the declared
|
||||
// header buys: a credential that cannot arrive anywhere it would be logged.
|
||||
{"app: no token", apps, "", `{"name":"x"}`, 401, nope},
|
||||
{"app: wrong token", apps, "Bearer nope", `{"name":"x"}`, 401, nope},
|
||||
{"app: not a bearer", apps, svcToken, `{"name":"x"}`, 401, nope},
|
||||
{"app: token in the body", apps, "", `{"name":"x","Auth":"` + bearer + `"}`, 401, nope},
|
||||
{"app: token in the query", apps + "?Auth=" + url.QueryEscape(bearer), "", `{"name":"x"}`, 401, nope},
|
||||
{"app: header named in the query", apps + "?Authorization=" + url.QueryEscape(bearer), "", `{"name":"x"}`, 401, nope},
|
||||
{"user: no token", users, "", `{"owner":"hanzo","name":"z"}`, 401, nope},
|
||||
|
||||
// The body, before a handler looks at it.
|
||||
{"app: no body", apps, bearer, ``, 400,
|
||||
`{"msg":"invalid body: empty request body","status":"error"}`},
|
||||
{"user: no body", users, bearer, ``, 400,
|
||||
`{"msg":"invalid body: empty request body","status":"error"}`},
|
||||
{"app: null body", apps, bearer, `null`, 400,
|
||||
`{"msg":"name is required","status":"error"}`},
|
||||
|
||||
// What each upsert insists on.
|
||||
{"app: no name", apps, bearer, `{}`, 400,
|
||||
`{"msg":"name is required","status":"error"}`},
|
||||
{"app: blank name", apps, bearer, `{"name":" "}`, 400,
|
||||
`{"msg":"name is required","status":"error"}`},
|
||||
{"app: nothing to sign with", apps, bearer, `{"name":"x"}`, 400,
|
||||
`{"msg":"application \"x\" would have no signing cert and no organization to derive one ` +
|
||||
"from, so it could never issue a token: state `cert`" + `","status":"error"}`},
|
||||
{"user: no owner", users, bearer, `{"name":"z"}`, 400,
|
||||
`{"msg":"owner and name are required","status":"error"}`},
|
||||
{"user: no name", users, bearer, `{"owner":"hanzo"}`, 400,
|
||||
`{"msg":"owner and name are required","status":"error"}`},
|
||||
{"user: unusable name", users, bearer, `{"owner":"hanzo","name":"Not A Name"}`, 400,
|
||||
`{"msg":"username \"Not A Name\" is not usable: use 1-63 characters of a-z, 0-9, dot, ` +
|
||||
`underscore or hyphen, starting with a letter or digit","status":"error"}`},
|
||||
|
||||
// What each upsert answers when it works. The secret is stated, so the
|
||||
// whole body is deterministic.
|
||||
{"app: created", apps, bearer,
|
||||
`{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","clientSecret":"s3cret"}`, 200,
|
||||
`{"action":"created","data":{"clientId":"hanzo-kms","clientSecret":"s3cret",` +
|
||||
`"name":"hanzo-kms","organization":"hanzo"},"status":"ok"}`},
|
||||
{"app: updated", apps, bearer,
|
||||
`{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","clientSecret":"s3cret"}`, 200,
|
||||
`{"action":"updated","data":{"clientId":"hanzo-kms","clientSecret":"s3cret",` +
|
||||
`"name":"hanzo-kms","organization":"hanzo"},"status":"ok"}`},
|
||||
{"user: created", users, bearer, `{"owner":"hanzo","name":"svc-signer"}`, 200,
|
||||
`{"action":"created","data":{"name":"svc-signer","owner":"hanzo"},"status":"ok"}`},
|
||||
{"user: updated", users, bearer, `{"owner":"hanzo","name":"svc-signer"}`, 200,
|
||||
`{"action":"updated","data":{"name":"svc-signer","owner":"hanzo"},"status":"ok"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, got := raw(t, app, tc.path, tc.auth, tc.body)
|
||||
if st != tc.status || got != tc.want {
|
||||
t.Errorf("POST %s\n got %d %s\nwant %d %s", tc.path, st, got, tc.status, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A body that is JSON but not THIS body is refused in this surface's envelope,
|
||||
// which is the whole reason the input records its own decode outcome rather than
|
||||
// letting the framework render the failure. The sentence is the decoder's own and
|
||||
// is not pinned; the envelope around it is ours and is.
|
||||
//
|
||||
// A body that is not JSON AT ALL never reaches the op — encoding/json rejects the
|
||||
// syntax before any Unmarshaler runs, so zip answers 400 in its own
|
||||
// {status,error} envelope. That seam belongs to the framework; everything after
|
||||
// it belongs here.
|
||||
func TestWireDecode(t *testing.T) {
|
||||
app := wire(t)
|
||||
st, got := raw(t, app, "/v1/iam/admin/applications/upsert", "Bearer "+svcToken, `{"name":5}`)
|
||||
if st != 400 || !strings.HasPrefix(got, `{"msg":"invalid body: `) || !strings.HasSuffix(got, `","status":"error"}`) {
|
||||
t.Errorf("got %d %s, want 400 in this surface's error envelope", st, got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import (
|
||||
func init() {
|
||||
zip.Describe("POST /v1/iam/admin/applications/upsert", zip.Doc{
|
||||
Description: "Creates an application or updates it in place, so a\ndeployment can declare the applications it needs and run the same declaration\non every environment and on every redeploy.\n\nIt says which of the two it did. Leave the client secret out and the existing\none is kept — so re-running your deployment does not rotate a credential your\nrunning services are holding.",
|
||||
Fields: map[string]string{
|
||||
"registration.expireInHours": "ExpireInHours and RefreshExpireInHours are the application's token\nlifetimes. They are the ONLY declarative way to say that a refresh token\nmust OUTLIVE its access token: with neither stated, oidc.refreshTTL clamps\nthe refresh lifetime to the access lifetime, so the refresh_token grant the\nregistration advertises expires at the same instant as the token it was\nmeant to renew and can never be exercised. `hanzo-cli` sat in exactly that\nstate — a browser re-login every hour, and a live refresh returning 401.\n\nPOINTERS, for the same reason as IsShared: a plain float would read as 0 on\nevery reconcile that says nothing and reset a deliberate lifetime back to\nthe default. Nil means \"not stated, leave it\".",
|
||||
"registration.isShared": "IsShared declares that this application serves EVERY organization, not only\nthe one named in Organization. It is the honest description of a brand app —\nhanzo-id, hanzo-chat, a brand console — whose customers each live in their own\ntenant: self-service onboarding moves a founder OUT of the brand org, so\n`user.Owner != app.Organization` is the steady state and the app really does\nserve every org. Application.ServesOrg reads it as one of the three ways to\nsay yes.\n\nA POINTER because omission must PRESERVE. This upsert is the operator's\nsteady-state reconcile and most callers say nothing about sharing; a plain\nbool would read as false on every one of them and silently un-share an app —\nthe same shape of accident that de-secreted apps through update-application.\nNil means \"not stated, leave it\"; only an explicit true or false moves it.",
|
||||
"registration.public": "Public declares a client that CANNOT hold a credential — a browser SPA,\na CLI, a desktop app. It proves itself with PKCE instead, and the token\nendpoint treats \"no stored secret\" as exactly that (token.go: a secret is\nverified only when one is stored). Without this flag every upsert minted\na secret, so a public client could never be registered at all and its\nbrowser code->token exchange 401'd `invalid_client` forever.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/admin/users/upsert", zip.Doc{
|
||||
Description: "Creates a person or updates them in place, so a deployment can\ndeclare the accounts it needs and re-run that declaration safely.\n\nPasswords are hashed before they are stored. Leave the password out and their\ncurrent one is kept, so a redeploy never locks somebody out.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package certs serves the IAM v2 CRUD surface for the `certs` entity: a
|
||||
// signing / TLS certificate owner-scoped by (owner, name). Every operation is a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package compare implements the Phase-0 drift gate: it counts rows per
|
||||
// entity in the v1 the legacy surface database and the v2 orm store and prints the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
//go:build migration
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
//go:build !migration
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package compat serves the legacy VERB surface (get-users, get-organizations,
|
||||
// …) over iam's orm store, in the v1 Response envelope. It exists because every
|
||||
@@ -267,7 +268,7 @@ func userGetHandler(db orm.DB) zip.Handler {
|
||||
// the four fields cloud's key resolver consumes (auth_apikey.go) and no more. It is
|
||||
// a TIGHTER redaction than schema.User.Mask, deliberately: Mask blanks the secret
|
||||
// digests and bearer tokens but leaves AccessKey populated, and an sk- resolution
|
||||
// must never disclose the resolved user's OTHER credential (its hk- AccessKey) to a
|
||||
// must never disclose the resolved user's OTHER credential (the value on its User row) to a
|
||||
// caller that only presented a secret key. A projection carrying no secret field is
|
||||
// leak-proof by construction.
|
||||
type keyUser struct {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
@@ -87,7 +88,9 @@ func newHarness(t *testing.T) *harness {
|
||||
|
||||
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
// GAP B — get-user?accessKey: cloud's identity boundary resolves an opaque SECRET API
|
||||
// key (hk-/sk-) to {owner,name,email,isAdmin} to authenticate a keyed request. It is
|
||||
// key (sk-) to {owner,name,email,isAdmin} to authenticate a keyed request. It is
|
||||
// SECURITY-CRITICAL: the caller presents a secret key and learns who it belongs to,
|
||||
// so it is gated behind the CapKeyResolve service capability, fails closed on an
|
||||
// unknown key, and NEVER leaks a secret field — in particular never the resolved
|
||||
// user's OTHER credential (its hk- AccessKey) on an sk- resolution. A PUBLIC pk- is
|
||||
// write-only and is REFUSED here (its org-only dual is /v1/iam/resolve-key).
|
||||
// user's OTHER credential (the value on its User row) on an sk- resolution. A PUBLIC
|
||||
// pk- is write-only and is REFUSED here (its org-only dual is /v1/iam/resolve-key).
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -30,7 +31,10 @@ const (
|
||||
otherApp = "hanzo-noresolve" // admin-owned app WITHOUT the capability
|
||||
svcSecret = "resolver-secret"
|
||||
|
||||
keyUserHK = "hk-live-KEYUSERHK" // the user's own durable Cloud API key
|
||||
// A value stamped on schema.User.AccessKey. NOTHING resolves that field, so this
|
||||
// authenticates nobody — it is a sentinel proving both that a user-row value is
|
||||
// never a credential and that its retired prefix is not a key shape.
|
||||
userRowKey = "hk-live-KEYUSERHK"
|
||||
keyUserSecretHash = "SENTINEL_ACCESS_SECRET_HASH"
|
||||
projPK = "pk-live-KEYUSERPK" // publishable half of a schema.Key
|
||||
projSK = "sk-live-KEYUSERPKSECRET" // confidential half of the same Key
|
||||
@@ -65,9 +69,9 @@ func (h *harness) getBasic(t *testing.T, path, clientID, secret string) (int, st
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// keyFixtures seeds the two service apps, the target user (with an hk- key + secret
|
||||
// sentinels), and a schema.Key (pk-/sk-) belonging to that user; then arms the
|
||||
// CapKeyResolve allowlist with resolverApp only.
|
||||
// keyFixtures seeds the two service apps, the target user (with secret sentinels and a
|
||||
// non-resolving value on its User row), and a schema.Key (pk-/sk-) belonging to that
|
||||
// user; then arms the CapKeyResolve allowlist with resolverApp only.
|
||||
func keyFixtures(t *testing.T, h *harness) {
|
||||
t.Helper()
|
||||
seedClientApp(t, h.db, resolverApp, svcSecret)
|
||||
@@ -76,7 +80,7 @@ func keyFixtures(t *testing.T, h *harness) {
|
||||
u := orm.New[schema.User](h.db)
|
||||
u.Owner, u.Name, u.Email = "hanzo", "keyuser", "keyuser@hanzo.ai"
|
||||
u.IsAdmin = true
|
||||
u.AccessKey = keyUserHK
|
||||
u.AccessKey = userRowKey
|
||||
u.AccessSecret = projSK // a secret half on the user row too — must never surface
|
||||
u.AccessSecretHash = keyUserSecretHash
|
||||
u.PasswordHash = secretUserHash
|
||||
@@ -109,7 +113,7 @@ func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cap-holding service caller resolves each SECRET key shape to the right user, with
|
||||
// A cap-holding service caller resolves the SECRET key shape to the right user, with
|
||||
// the exact {owner,name,email,isAdmin} cloud consumes — and NO secret ever appears —
|
||||
// while the PUBLIC publishable pk- is REFUSED, so a public key can never become a read
|
||||
// principal at cloud's identity boundary.
|
||||
@@ -118,7 +122,6 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
|
||||
keyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key string }{
|
||||
{"hk on user row", keyUserHK},
|
||||
{"sk confidential half", projSK},
|
||||
} {
|
||||
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
|
||||
@@ -137,8 +140,8 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
|
||||
t.Fatalf("%s: data=%+v, want hanzo/keyuser keyuser@hanzo.ai isAdmin=true", tc.name, e.Data)
|
||||
}
|
||||
// No secret material, and — critically — not the user's OTHER credential
|
||||
// (its hk- key) when an sk- key was the one presented.
|
||||
for _, secret := range []string{secretUserHash, keyUserSecretHash, keyUserHK} {
|
||||
// (the value on its User row) when an sk- key was the one presented.
|
||||
for _, secret := range []string{secretUserHash, keyUserSecretHash, userRowKey} {
|
||||
if tc.key != secret && strings.Contains(body, secret) {
|
||||
t.Fatalf("%s: SECRET LEAK %q in body:\n%s", tc.name, secret, body)
|
||||
}
|
||||
@@ -158,6 +161,35 @@ func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// There are exactly TWO key shapes. A value carrying a retired prefix is not a key —
|
||||
// not a deprecated one, not an accepted-for-now one — and it authenticates NOBODY even
|
||||
// when that exact value is stamped on a real, live user's row.
|
||||
//
|
||||
// This is the sharp end of the one-way property: keyFixtures puts userRowKey on
|
||||
// hanzo/keyuser, so a resurrected prefix branch (or any new read of
|
||||
// schema.User.AccessKey as a credential) would resolve it to an ADMIN principal and
|
||||
// fail here loudly. The refusal must also carry key_unknown, which is what renders the
|
||||
// actionable "mint a new one at cloud.hanzo.ai/keys" for the holder — never
|
||||
// key_wrong_door, whose advice ("use your secret key") would be a lie to someone whose
|
||||
// credential no longer exists.
|
||||
func TestGetUserByAccessKey_RetiredPrefixIsNotAKey(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+userRowKey, resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" {
|
||||
t.Fatalf("a retired prefix resolved: env=%+v body=%s", e, body)
|
||||
}
|
||||
if e.Code != "key_unknown" {
|
||||
t.Errorf("code = %q, want key_unknown (the actionable 'mint a new one' path)", e.Code)
|
||||
}
|
||||
if strings.Contains(body, "keyuser") {
|
||||
t.Fatalf("a retired prefix leaked the principal identity: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// F1 REGRESSION — end to end: a forged Key (planted in the attacker's own org but
|
||||
// pointing User at the reserved admin org = SuperAdmin) must yield NO identity
|
||||
// through the real get-user?accessKey path, even to the cap-holding service caller.
|
||||
@@ -195,7 +227,7 @@ func TestGetUserByAccessKey_NonCapDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
|
||||
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projSK, otherApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
|
||||
@@ -246,10 +278,11 @@ func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
|
||||
keyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key, wantCode string }{
|
||||
{"revoked / never minted", "hk-live-NOSUCHKEY", "key_unknown"},
|
||||
{"revoked / never minted", "sk-live-NOSUCHKEY2", "key_unknown"},
|
||||
{"unknown secret half", "sk-live-NOSUCHKEY", "key_unknown"},
|
||||
{"a publishable key at the SECRET door", projPK, "key_wrong_door"},
|
||||
{"an unrecognized shape", "fw_deadbeef", "key_wrong_door"},
|
||||
{"an unrecognized shape", "fw_deadbeef", "key_unknown"},
|
||||
{"a retired prefix", "hk-live-NOSUCHKEY", "key_unknown"},
|
||||
} {
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
@@ -274,7 +307,7 @@ func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projSK, otherApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Code != "" {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
@@ -120,7 +121,7 @@ func TestResolveKey_RefusesNonPublishable(t *testing.T) {
|
||||
for _, tc := range []struct{ name, key string }{
|
||||
{"secret key's pk- half", secretKeyPK},
|
||||
{"an sk- confidential half", secretKeySK},
|
||||
{"an hk-", "hk-live-anything"},
|
||||
{"a retired prefix", "hk-live-anything"},
|
||||
{"unknown pk-", "pk-live-NOSUCH"},
|
||||
{"empty", ""},
|
||||
} {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
@@ -144,28 +145,42 @@ func TestWriteAliases_requireAuth(t *testing.T) {
|
||||
|
||||
// The FRONT-DOOR session routes are structurally PUBLIC — registered on the
|
||||
// pre-Guard group, so reachable WITHOUT a bearer (the portal + gateway admin-guard
|
||||
// call them with a session cookie). An anonymous caller gets the casibase
|
||||
// {status:"error"} (200), never a 401 and never a leak.
|
||||
// call them with a session cookie). What proves that is the HANDLER's own envelope
|
||||
// coming back: the Guard refuses before any handler runs and answers its own
|
||||
// shape, so a body carrying {"status":"error"} is evidence the request got past
|
||||
// it. The STATUS is a separate fact, and these routes differ honestly:
|
||||
//
|
||||
// - whoami / get-account ASK a question ("who am I?"), and "nobody" is a
|
||||
// complete answer — 200.
|
||||
// - linked-accounts asks for a RESOURCE that requires an identity, so an
|
||||
// anonymous caller is refused — a 4xx carrying CodeLoginRequired, which is
|
||||
// the machine-readable "sign in" (see internal/httpx on why not 401).
|
||||
//
|
||||
// Neither leaks, and neither is the Guard's blanket refusal.
|
||||
func TestFrontDoorPublic_ReachableWithoutBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{"GET", "/v1/iam/get-account"},
|
||||
{"GET", "/v1/iam/whoami"},
|
||||
{"GET", "/v1/iam/linked-accounts"},
|
||||
{"GET", "/v1/iam/get-account", 200},
|
||||
{"GET", "/v1/iam/whoami", 200},
|
||||
{"GET", "/v1/iam/linked-accounts", 400},
|
||||
} {
|
||||
status, body := h.get(t, tc.path, "")
|
||||
if status != 200 {
|
||||
t.Fatalf("%s %s without a bearer status=%d, want 200 (public); body=%s", tc.method, tc.path, status, body)
|
||||
// Past the Guard: the handler's own envelope, not the Guard's shape.
|
||||
if !strings.Contains(body, `"status":"error"`) {
|
||||
t.Fatalf("anonymous %s %s must reach the handler and return its error envelope; status=%d body=%s",
|
||||
tc.method, tc.path, status, body)
|
||||
}
|
||||
if !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous %s must be the casibase error envelope; body=%s", tc.path, body)
|
||||
if status != tc.want {
|
||||
t.Fatalf("%s %s without a bearer status=%d, want %d; body=%s", tc.method, tc.path, status, tc.want, body)
|
||||
}
|
||||
}
|
||||
// signin (a POST) is public too — anonymous, no code → a 200 error, not a 401.
|
||||
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 200 || !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous signin status=%d body=%s, want 200 error (public)", status, body)
|
||||
// signin (a POST) is public too: it REACHES its handler and is refused on the
|
||||
// merits ("code is required"), which is a 4xx — not the Guard's blanket 401.
|
||||
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 400 || !strings.Contains(body, `"status":"error"`) {
|
||||
t.Fatalf("anonymous signin status=%d body=%s, want 400 + the handler's envelope (public)", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -197,7 +197,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -265,7 +265,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
|
||||
+307
-48
@@ -1,7 +1,9 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package cors lets a registered browser client complete OIDC against this
|
||||
// IdP from its own origin.
|
||||
// IdP from its own origin, and lets a first-party console sign a user in and
|
||||
// out from its own.
|
||||
//
|
||||
// A public (PKCE) client runs the code->token exchange in the BROWSER: the page
|
||||
// at https://<app-host> fetches https://<idp-host>/v1/iam/oauth/token directly.
|
||||
@@ -9,23 +11,67 @@
|
||||
// browser blocks the response and the user parks forever on the callback with
|
||||
// "Failed to fetch" — authenticated, holding a valid code, unable to spend it.
|
||||
//
|
||||
// THE ALLOWLIST IS DERIVED, NOT CONFIGURED. An origin is permitted iff some
|
||||
// registered application already declares a redirect_uri on it. That is the
|
||||
// same set OAuth itself trusts to receive an authorization code, so CORS can
|
||||
// never be looser than the redirect allowlist, and there is no second list to
|
||||
// keep in sync: provision a host, and login works from it. A config-file
|
||||
// allowlist is exactly how these two drift apart.
|
||||
//
|
||||
// Only the endpoints a browser legitimately calls cross-origin are opened.
|
||||
// Credentials are NOT allowed: a PKCE exchange carries its proof in the body,
|
||||
// not in a cookie, so echoing an origin can never authorize a cookie-bearing
|
||||
// request.
|
||||
//
|
||||
// # Two questions, never one
|
||||
//
|
||||
// CORS is asked two different things about an Origin, and answering both from
|
||||
// one list is a privilege escalation:
|
||||
//
|
||||
// 1. May this origin READ the answer? Answered by the DERIVED allowlist: an
|
||||
// origin is permitted iff some registered application already declares a
|
||||
// redirect_uri on it. That is the same set OAuth itself trusts to receive an
|
||||
// authorization code, so this grant can never be looser than the redirect
|
||||
// allowlist, and there is no second list to keep in sync — provision a host
|
||||
// and login works from it.
|
||||
//
|
||||
// 2. May this origin send the request WITH THE USER'S COOKIE and read what
|
||||
// comes back? Answered by consoles ∩ [cookie]: an exact origin an OPERATOR
|
||||
// listed in IAM_SESSION_ORIGINS, on a path marked [cookie] in the table
|
||||
// below.
|
||||
//
|
||||
// The second is strictly narrower and CANNOT be derived from the first. A tenant
|
||||
// admin may register an application in their OWN organization with a
|
||||
// redirect_uri on a host they control, which puts that host in the derived set.
|
||||
// Echoing such an origin is harmless while the answer carries no ambient
|
||||
// authority — a PKCE exchange proves itself in the body, not in a cookie, and a
|
||||
// Bearer read proves itself in a header an attacker's page does not have.
|
||||
//
|
||||
// # What question 2 actually grants, stated plainly
|
||||
//
|
||||
// POST /v1/iam/login answers a code request that carries no credential but a
|
||||
// live session cookie by MINTING AN AUTHORIZATION CODE — the single-sign-on
|
||||
// branch in internal/oidc/login.go. So an origin on this list can, from a page a
|
||||
// signed-in user merely visits, mint a code for that user and spend it. That is
|
||||
// account takeover, not a disclosure. Every entry on the list is that powerful,
|
||||
// which is why it is exact origins, short, and an operator's deliberate act.
|
||||
//
|
||||
// A SUFFIX is the wrong shape for it, even though a brand-suffix config already
|
||||
// exists elsewhere in the fleet (IAM_TRUSTED_ORIGIN_SUFFIXES): this fleet serves
|
||||
// *.hanzo.app as customer-published sites, so "hanzo.app" read as a suffix would
|
||||
// name every customer's published page a first-party console and hand it that
|
||||
// grant. An entry names the console, not the domain the console sits under.
|
||||
//
|
||||
// An origin outside BOTH sets gets no Access-Control-Allow-Origin header at all.
|
||||
// It is never echoed, and there is no wildcard: `*` with credentials is invalid
|
||||
// per the Fetch standard, and `*` without them would open every browser path to
|
||||
// every page on the internet.
|
||||
//
|
||||
// # This is not the only answer a browser gets
|
||||
//
|
||||
// A reverse proxy in front of this process can append CORS headers of its own,
|
||||
// and nothing here can undo that: an appended Access-Control-Allow-Origin
|
||||
// overrides every decision this package makes. This package's job is to answer
|
||||
// correctly ON ITS OWN, so that such a rule can be narrowed to nothing without
|
||||
// taking a login down with it.
|
||||
package cors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -33,23 +79,62 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/internal/wallet"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// browserPaths are the endpoints a browser-side OIDC client must reach
|
||||
// cross-origin. Everything else stays same-origin only — an endpoint that no
|
||||
// browser client calls has no reason to advertise itself to one.
|
||||
var browserPaths = map[string]bool{
|
||||
"/.well-known/openid-configuration": true,
|
||||
"/v1/iam/.well-known/openid-configuration": true,
|
||||
"/.well-known/oauth-authorization-server": true,
|
||||
"/v1/iam/.well-known/oauth-authorization-server": true,
|
||||
"/.well-known/jwks": true,
|
||||
"/v1/iam/.well-known/jwks": true,
|
||||
"/v1/iam/oauth/token": true,
|
||||
"/v1/iam/oauth/userinfo": true,
|
||||
"/v1/iam/oauth/revoke": true,
|
||||
"/v1/iam/oauth/logout": true,
|
||||
// credential says which proof a path's caller presents, and therefore whether a
|
||||
// cross-origin request to it may carry the browser's ambient SSO cookie.
|
||||
//
|
||||
// It lives ON the path table rather than in a second set, so the security fact
|
||||
// sits on the SAME LINE as the path it describes: there is no pair of maps to
|
||||
// cross-reference, and no way to add a path to one and forget the other.
|
||||
//
|
||||
// It is deliberately NOT a bool. The ZERO value has to be the CLOSED one, so a
|
||||
// lookup that misses answers `absent` rather than the safest-looking of two real
|
||||
// states — and `if browserPaths[p]` does not compile, so nobody can read a
|
||||
// three-state fact as a two-state one.
|
||||
type credential uint8
|
||||
|
||||
const (
|
||||
// absent: not a browser path. The zero value, so a map miss says this.
|
||||
absent credential = iota
|
||||
// bearer: the caller proves itself IN the request — a Bearer token, a PKCE
|
||||
// verifier, a client secret. The ambient cookie adds nothing, so it is not
|
||||
// allowed, and an attacker's page holds none of those proofs.
|
||||
bearer
|
||||
// cookie: a first-party console's request to this path is sent with
|
||||
// credentials, so the answer must say the credential was allowed or the
|
||||
// browser discards it and the console breaks.
|
||||
cookie
|
||||
)
|
||||
|
||||
// browserPaths are the endpoints a browser-side client must reach cross-origin,
|
||||
// each marked with the proof its caller presents. Everything else stays
|
||||
// same-origin only — an endpoint no browser client calls has no reason to
|
||||
// advertise itself to one.
|
||||
//
|
||||
// The [cookie] entries are exactly the five sites the shipped SDK
|
||||
// (hanzoai/js-iam, src/browser.ts) sends `credentials: "include"` to. That is
|
||||
// the whole criterion, and it is a CLIENT fact rather than a server one: a fetch
|
||||
// made with credentials is discarded by the browser unless the response carries
|
||||
// Access-Control-Allow-Credentials, whether or not the handler reads a cookie.
|
||||
// Withholding the header on one of them withholds no privilege — it breaks the
|
||||
// call.
|
||||
//
|
||||
// The five are named by their ROUTE CONSTANT rather than a literal, because they
|
||||
// are the powerful ones: a path that drifted out of sync with its route would
|
||||
// fail open at a proxy and closed here, and the compiler catches that.
|
||||
var browserPaths = map[string]credential{
|
||||
"/.well-known/openid-configuration": bearer,
|
||||
"/v1/iam/.well-known/openid-configuration": bearer,
|
||||
"/.well-known/oauth-authorization-server": bearer,
|
||||
"/v1/iam/.well-known/oauth-authorization-server": bearer,
|
||||
"/.well-known/jwks": bearer,
|
||||
"/v1/iam/.well-known/jwks": bearer,
|
||||
"/v1/iam/oauth/token": bearer,
|
||||
"/v1/iam/oauth/userinfo": bearer,
|
||||
|
||||
// The org surface an authenticated SPA reads about ITSELF. A console shows
|
||||
// "which org am I acting as" and lets the user switch; that answer lives
|
||||
@@ -62,10 +147,15 @@ var browserPaths = map[string]bool{
|
||||
// caller sees only what its principal could already see. CORS decides which
|
||||
// ORIGIN may read the answer; authz decides WHO. Same shape as userinfo
|
||||
// above, which is already open and already Bearer-protected.
|
||||
"/v1/iam/get-organizations": true,
|
||||
"/v1/iam/get-organization": true,
|
||||
"/v1/iam/get-users": true,
|
||||
"/v1/iam/get-account": true,
|
||||
//
|
||||
// get-account is the one that ALSO answers from the SSO cookie, and it stays
|
||||
// [bearer] deliberately: it is the account object, it is exactly what the
|
||||
// live proxy defect disclosed, and no console asks for it with credentials.
|
||||
// A console reads it with the Bearer it already holds.
|
||||
"/v1/iam/get-organizations": bearer,
|
||||
"/v1/iam/get-organization": bearer,
|
||||
"/v1/iam/get-users": bearer,
|
||||
"/v1/iam/get-account": bearer,
|
||||
|
||||
// The two writes a first-party console performs on the user's OWN behalf:
|
||||
// create an org, invite someone to it. Both are Guard-authorized against the
|
||||
@@ -73,8 +163,126 @@ var browserPaths = map[string]bool{
|
||||
// already do. Listed as the NATIVE REST paths, not the legacy verbs — those
|
||||
// are a compatibility surface for existing backends, not something a new
|
||||
// browser client should learn.
|
||||
"/v1/iam/organizations": true,
|
||||
"/v1/iam/invitations": true,
|
||||
"/v1/iam/organizations": bearer,
|
||||
"/v1/iam/invitations": bearer,
|
||||
|
||||
// Sign IN with a typed credential. browser.ts credentialLogin (reached by
|
||||
// loginWithPassword and loginWithCode) posts here with credentials, and the
|
||||
// single-sign-on branch answers a bare code request from the cookie alone.
|
||||
// This is the account-takeover grant described in the package comment, and
|
||||
// it is the reason the list is exact origins.
|
||||
oidc.PathLogin: cookie,
|
||||
|
||||
// Sign in with a WALLET: browser.ts loginWithWallet, the admin-console
|
||||
// SuperAdmin path. Both legs are sent with credentials. NEITHER handler
|
||||
// reads the SSO cookie today — the header is required because the SDK asks
|
||||
// for one, not because the server spends one.
|
||||
wallet.PathNonce: cookie,
|
||||
wallet.PathVerify: cookie,
|
||||
|
||||
// Sign OUT: revoke the tokens (RFC 7009), then end the session (OIDC
|
||||
// RP-initiated logout). browser.ts logout() sends both with credentials.
|
||||
//
|
||||
// Neither handler reads or clears the SSO cookie either — revoke
|
||||
// authenticates the CLIENT and deletes a token row, and logout validates a
|
||||
// signature-verified id_token_hint to decide a redirect. So the SDK's
|
||||
// comment that credentials are "the difference between ending the session
|
||||
// and appearing to" describes an intent the server does not implement: the
|
||||
// portal session outlives an RP-initiated logout. That is a defect in the
|
||||
// PAIR, and its fix belongs in the handler. Until then this header is only
|
||||
// what keeps the shipped call from failing.
|
||||
oidc.PathRevoke: cookie,
|
||||
oidc.PathLogout: cookie,
|
||||
}
|
||||
|
||||
// env names the operator's list of first-party console origins.
|
||||
const env = "IAM_SESSION_ORIGINS"
|
||||
|
||||
// consoles is a set of exact serialized origins — a value, not a place: built
|
||||
// once when the middleware is constructed and read by every request goroutine
|
||||
// without a lock.
|
||||
type consoles map[string]bool
|
||||
|
||||
// has reports membership by EXACT string equality against a canonical origin,
|
||||
// never a suffix, prefix or pattern. "https://hanzo.ai.evil.com",
|
||||
// "https://evil-hanzo.ai", "https://HANZO.AI", "https://hanzo.ai." and
|
||||
// "https://hanzo.ai:8443" are all misses rather than near-hits.
|
||||
func (c consoles) has(origin string) bool { return c[origin] }
|
||||
|
||||
// exact reports whether raw is ALREADY the serialized origin RFC 6454 defines —
|
||||
// scheme://host[:port] and nothing else.
|
||||
//
|
||||
// It is a reconstruct-and-compare, so ONE comparison rejects a path, a query, a
|
||||
// fragment, userinfo, a trailing slash, an upper-case scheme and (via url.Parse,
|
||||
// which refuses them outright) any embedded control character. Applied to the
|
||||
// REQUEST header this is what makes echoing it safe: the only strings that can
|
||||
// reach the response already equal their own canonical serialization, so there
|
||||
// is nothing left to smuggle. Applied to CONFIG it is what keeps a bare domain,
|
||||
// a suffix or a wildcard out of an exact list.
|
||||
func exact(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return false
|
||||
}
|
||||
if raw != u.Scheme+"://"+u.Host {
|
||||
return false
|
||||
}
|
||||
return host(u.Hostname())
|
||||
}
|
||||
|
||||
// host reports whether h is a plain DNS name: letters, digits and hyphens in
|
||||
// non-empty labels separated by dots.
|
||||
//
|
||||
// It is what rejects "*.hanzo.ai" — an operator writing the suffix they MEANT,
|
||||
// which url.Parse is happy to call a host and which would then sit in the list
|
||||
// matching nothing, the silent misconfiguration this package exists to refuse.
|
||||
// It also rejects a TRAILING DOT: "hanzo.ai." resolves the same but is a
|
||||
// different cookie scope and a different origin, so it is not our console.
|
||||
func host(h string) bool {
|
||||
if h == "" || strings.HasPrefix(h, ".") || strings.HasSuffix(h, ".") || strings.Contains(h, "..") {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(h); i++ {
|
||||
switch c := h[i]; {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parse reads the comma-separated IAM_SESSION_ORIGINS list. Each entry must be
|
||||
// an https origin and nothing more.
|
||||
//
|
||||
// A malformed entry is an ERROR, not a skip: silently dropping one would deny a
|
||||
// single brand's console its sign-in while every other brand kept working — the
|
||||
// failure mode that is hardest to notice and slowest to diagnose. Host case IS
|
||||
// forgiven, because a browser always lower-cases it and refusing an operator's
|
||||
// capitalization would fail a boot over nothing.
|
||||
func parse(list string) (consoles, error) {
|
||||
out := consoles{}
|
||||
for _, raw := range strings.Split(list, ",") {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Case is forgiven by LOWERCASING, never by re-parsing: rebuilding the
|
||||
// entry from url.Parse's scheme and host would silently DISCARD a path, a
|
||||
// query or userinfo and accept an entry the operator got wrong.
|
||||
v = strings.ToLower(v)
|
||||
if !strings.HasPrefix(v, "https://") || !exact(v) {
|
||||
return nil, fmt.Errorf(
|
||||
"%s: %q is not an https origin: want scheme://host[:port] — an exact "+
|
||||
"console origin such as https://console.hanzo.ai, never a bare domain, "+
|
||||
"a suffix or a wildcard", env, raw)
|
||||
}
|
||||
out[v] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// registry answers "is this origin registered?" from the application rows,
|
||||
@@ -152,32 +360,83 @@ func originOf(raw string) string {
|
||||
|
||||
// Allow returns the middleware. It runs before the route table, so it covers
|
||||
// the public OIDC group without any route needing to know about it.
|
||||
//
|
||||
// A malformed IAM_SESSION_ORIGINS PANICS here rather than degrading, and here is
|
||||
// the ONE place that runs in every deployment: routes.Route calls Allow, and
|
||||
// both the standalone `iam serve` and the cloud binary that embeds IAM
|
||||
// (iamserver.Route -> routes.Route) reach it before either opens a listener. A
|
||||
// gate wired into one main() is a gate the other deployment does not have. Same
|
||||
// shape, and the same reasoning, as the feature-module registration panic one
|
||||
// call up in iam/server.
|
||||
func Allow(db orm.DB) zip.Handler {
|
||||
listed, err := parse(os.Getenv(env))
|
||||
if err != nil {
|
||||
panic("iam/cors: " + err.Error())
|
||||
}
|
||||
return allow(db, listed)
|
||||
}
|
||||
|
||||
// allow is Allow over an explicit set — the seam a test drives without the
|
||||
// environment.
|
||||
func allow(db orm.DB, listed consoles) zip.Handler {
|
||||
reg := ®istry{db: db, ttl: 60 * time.Second}
|
||||
return func(c *zip.Ctx) error {
|
||||
origin := strings.TrimSpace(c.Header("Origin"))
|
||||
if origin == "" {
|
||||
return c.Next() // same-origin or a non-browser client
|
||||
}
|
||||
path := c.Path()
|
||||
if !browserPaths[path] {
|
||||
mode := browserPaths[c.Path()]
|
||||
if mode == absent {
|
||||
return c.Next()
|
||||
}
|
||||
// Vary on Origin whenever the response could depend on it, so a shared
|
||||
// cache can never serve one origin's response to another.
|
||||
c.SetHeader("Vary", "Origin")
|
||||
if !reg.allowed(c.Context(), origin) {
|
||||
return c.Next() // unregistered: no header, browser blocks it
|
||||
|
||||
// An Origin header that is empty (same-origin, or a non-browser client) or
|
||||
// that is not a serialized origin at all — "null", a bare domain, something
|
||||
// carrying a path, anything padded with whitespace — leaves echo empty, and
|
||||
// nothing is echoed. The header is read RAW: exact() is a total rule, and a
|
||||
// trim would be a second one carved out beside it.
|
||||
echo, credentialed := "", false
|
||||
if origin := c.Header("Origin"); exact(origin) {
|
||||
// Question 1 — may it read at all? A console an operator listed is
|
||||
// first-party and always may; anyone else must have registered.
|
||||
console := listed.has(origin)
|
||||
if console || reg.allowed(c.Context(), origin) {
|
||||
echo = origin
|
||||
}
|
||||
// Question 2 — may it spend the user's cookie? Only a listed console,
|
||||
// and only on a path a console sends credentials to. Answered SEPARATELY
|
||||
// from question 1: widening what an origin may read must never widen
|
||||
// what it may spend.
|
||||
credentialed = console && mode == cookie
|
||||
}
|
||||
|
||||
c.SetHeader("Access-Control-Allow-Origin", origin)
|
||||
c.SetHeader("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.SetHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
c.SetHeader("Access-Control-Max-Age", "600")
|
||||
if echo != "" {
|
||||
c.SetHeader("Access-Control-Allow-Origin", echo)
|
||||
if credentialed {
|
||||
// On the preflight AND on the actual response. A preflight that
|
||||
// allows credentials and a response that does not is a request the
|
||||
// browser sends and then refuses to hand to the page.
|
||||
c.SetHeader("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
c.SetHeader("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.SetHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
c.SetHeader("Access-Control-Max-Age", "600")
|
||||
}
|
||||
|
||||
if c.Method() == http.MethodOptions {
|
||||
vary(c)
|
||||
return c.NoContent(http.StatusNoContent) // preflight ends here
|
||||
}
|
||||
return c.Next()
|
||||
err := c.Next()
|
||||
vary(c)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// vary appends Origin to the response's Vary header.
|
||||
//
|
||||
// AFTER the handler, and by APPENDING. Every answer on a browser path depends on
|
||||
// Origin — INCLUDING the answer that carries no CORS header at all — so a shared
|
||||
// cache must never hand one origin the response computed for another. Setting it
|
||||
// BEFORE the handler loses the race: a handler that sets its own Vary
|
||||
// (Accept-Encoding, on any negotiated response) REPLACES the header and the
|
||||
// protection silently disappears. Appending afterwards keeps both, and this
|
||||
// append is idempotent, so a handler that already varied on Origin does not get
|
||||
// it twice.
|
||||
func vary(c *zip.Ctx) { c.Fiber().Vary("Origin") }
|
||||
|
||||
+42
-13
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cors
|
||||
|
||||
@@ -46,8 +47,8 @@ func TestOriginOf_SkipsNonBrowserRedirects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only endpoints a browser-side OIDC client actually calls are opened. Widening
|
||||
// this set is a security decision, so the set is asserted rather than assumed.
|
||||
// Only endpoints a browser-side client actually calls are opened. Widening this
|
||||
// set is a security decision, so the set is asserted rather than assumed.
|
||||
func TestBrowserPaths_ExactlyTheOIDCBrowserSurface(t *testing.T) {
|
||||
// These MUST be open — the failure that motivated this package was the
|
||||
// token endpoint and discovery being blocked.
|
||||
@@ -57,24 +58,50 @@ func TestBrowserPaths_ExactlyTheOIDCBrowserSurface(t *testing.T) {
|
||||
"/v1/iam/.well-known/jwks",
|
||||
"/v1/iam/oauth/userinfo",
|
||||
} {
|
||||
if !browserPaths[p] {
|
||||
t.Errorf("%s must be reachable cross-origin", p)
|
||||
if browserPaths[p] != bearer {
|
||||
t.Errorf("%s must be reachable cross-origin, proving itself with a Bearer", p)
|
||||
}
|
||||
}
|
||||
// These MUST NOT be: admin/bootstrap surfaces and the credential-login
|
||||
// endpoint are not for cross-origin browser use.
|
||||
// The sign-in and sign-out surface the shipped SDK calls with credentials.
|
||||
// It is open AND cookie-bearing, to an exact console origin only.
|
||||
for _, p := range []string{
|
||||
"/v1/iam/login",
|
||||
"/v1/iam/web3/nonce",
|
||||
"/v1/iam/web3/verify",
|
||||
"/v1/iam/oauth/revoke",
|
||||
"/v1/iam/oauth/logout",
|
||||
} {
|
||||
if browserPaths[p] != cookie {
|
||||
t.Errorf("%s must be reachable cross-origin WITH credentials: hanzoai/js-iam "+
|
||||
"sends it with credentials:\"include\" and a browser discards the answer "+
|
||||
"unless the credential is allowed", p)
|
||||
}
|
||||
}
|
||||
// These MUST NOT be reachable at all: admin/bootstrap surfaces, and a
|
||||
// top-level redirect that is never a fetch.
|
||||
for _, p := range []string{
|
||||
"/v1/iam/admin/applications/upsert",
|
||||
"/v1/iam/admin/users/upsert",
|
||||
"/v1/iam/login",
|
||||
"/v1/iam/oauth/authorize", // a top-level redirect, not a fetch
|
||||
} {
|
||||
if browserPaths[p] {
|
||||
if browserPaths[p] != absent {
|
||||
t.Errorf("%s must NOT be opened cross-origin", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The zero value of the table is the CLOSED state. A path nobody listed must
|
||||
// read as `absent`, never as the safest-looking of the two real answers — that
|
||||
// is what makes a typo in a path fail closed instead of quietly becoming a
|
||||
// Bearer-readable endpoint.
|
||||
func TestBrowserPaths_AMissIsClosedNotBearer(t *testing.T) {
|
||||
for _, p := range []string{"", "/", "/v1/iam/lo gin", "/v1/iam/LOGIN", "/v1/iam/login/"} {
|
||||
if got := browserPaths[p]; got != absent {
|
||||
t.Errorf("browserPaths[%q] = %v, want absent — a miss must be closed", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The allowlist is derived from application rows; an origin nobody registered
|
||||
// is not allowed, and one that is registered is.
|
||||
func TestLoadDerivesTheAllowlistFromRedirectUris(t *testing.T) {
|
||||
@@ -111,9 +138,9 @@ func TestBrowserPaths_CoverTheConsoleOrgSurface(t *testing.T) {
|
||||
"/v1/iam/get-users",
|
||||
"/v1/iam/get-account",
|
||||
} {
|
||||
if !browserPaths[p] {
|
||||
t.Errorf("%s must be reachable cross-origin: a console reads it to show "+
|
||||
"which org the user is acting as", p)
|
||||
if browserPaths[p] != bearer {
|
||||
t.Errorf("%s must be reachable cross-origin with a Bearer: a console reads it to "+
|
||||
"show which org the user is acting as, and never with the ambient cookie", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,8 +154,10 @@ func TestBrowserPaths_StayClosedByDefault(t *testing.T) {
|
||||
"/v1/iam/get-providers", // provider secrets
|
||||
"/v1/iam/delete-user", // a write
|
||||
"/v1/iam/registry/token", // docker client, not a browser
|
||||
"/v1/iam/signin", // code->session exchange; a top-level navigation
|
||||
"/v1/iam/signup", // the SDK posts it same-origin from the IdP's own SPA
|
||||
} {
|
||||
if browserPaths[p] {
|
||||
if browserPaths[p] != absent {
|
||||
t.Errorf("%s is open to browsers but nothing browser-side calls it", p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cors
|
||||
|
||||
// The credentialed-CORS gate, driven as HTTP through the real middleware.
|
||||
//
|
||||
// The defect these cover: a proxy in front of this IdP answered an arbitrary
|
||||
// Origin with Access-Control-Allow-Origin PLUS Access-Control-Allow-Credentials,
|
||||
// on every path — including POST /v1/iam/login, whose single-sign-on branch mints
|
||||
// an authorization code from the SSO cookie alone. The cookie is host-only and
|
||||
// SameSite=Lax, so the origins that could actually spend it were the SAME-SITE
|
||||
// ones: a page on any *.hanzo.ai host reading iam.hanzo.ai. Every case below is a
|
||||
// request an attacker can actually send, and the whole contract is which headers
|
||||
// come back.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// The one origin an operator listed, and the one a tenant registered. They are
|
||||
// deliberately different hosts: the whole point of the split is that the second
|
||||
// never inherits what the first has.
|
||||
const (
|
||||
ours = "https://console.hanzo.ai" // IAM_SESSION_ORIGINS — may use the cookie
|
||||
theirs = "https://theirs.example" // a registered redirect_uri — may read only
|
||||
hostile = "https://evil.example.com"
|
||||
readPath = "/v1/iam/get-account" // reads the account: cookie NEVER admitted
|
||||
)
|
||||
|
||||
// signIn and signOut are the five sites hanzoai/js-iam src/browser.ts sends
|
||||
// `credentials: "include"` to. They are the contract this package answers, so
|
||||
// the test names them from the CLIENT, not from the server's path table.
|
||||
var (
|
||||
signIn = []string{
|
||||
"/v1/iam/login", // browser.ts credentialLogin (loginWithPassword/loginWithCode)
|
||||
"/v1/iam/web3/nonce", // browser.ts loginWithWallet, leg 1
|
||||
"/v1/iam/web3/verify", // browser.ts loginWithWallet, leg 2
|
||||
}
|
||||
signOut = []string{
|
||||
"/v1/iam/oauth/revoke", // browser.ts revoke (RFC 7009)
|
||||
"/v1/iam/oauth/logout", // browser.ts logout (end_session)
|
||||
}
|
||||
credentialed = append(append([]string{}, signIn...), signOut...)
|
||||
)
|
||||
|
||||
// sameSite are origins that are SAME-SITE with the IdP host iam.hanzo.ai, so
|
||||
// SameSite=Lax does NOT stop the browser attaching the SSO cookie to a request
|
||||
// they make. Nothing else stops them either — except this package refusing to
|
||||
// name them. *.hanzo.app is the customer-publishing plane (cloud/apps/projects
|
||||
// serves <slug>.hanzo.app); *.hanzo.ai is a live wildcard on the same registrable
|
||||
// domain as the IdP.
|
||||
var sameSite = []string{
|
||||
"https://zzz.hanzo.app",
|
||||
"https://zzz-random-9k2.hanzo.ai",
|
||||
"https://customer.hanzo.ai",
|
||||
"https://hanzo.ai",
|
||||
"https://hanzo.app",
|
||||
}
|
||||
|
||||
// probe drives one request through the middleware and reports the CORS headers
|
||||
// that came back.
|
||||
type probe struct {
|
||||
status int
|
||||
origin string // Access-Control-Allow-Origin
|
||||
credentials string // Access-Control-Allow-Credentials
|
||||
vary string
|
||||
}
|
||||
|
||||
// harness registers the middleware over a store holding ONE tenant-registered
|
||||
// application, so the derived allowlist is real rather than stubbed.
|
||||
//
|
||||
// Its terminal handler sets `Vary: Accept-Encoding` on every path, because that
|
||||
// is what a real handler does on a negotiated response and it is exactly what a
|
||||
// Vary written BEFORE the chain would lose.
|
||||
func harness(t *testing.T, listed consoles) func(method, path, origin string) probe {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(t.TempDir(), "cors.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "theirs", "theirs-app"
|
||||
a.RedirectUris = []string{theirs + "/callback"}
|
||||
a.SetId("theirs/theirs-app")
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed application: %v", err)
|
||||
}
|
||||
|
||||
app := zip.New(zip.Config{AppName: "cors-test", DisableStartupMessage: true})
|
||||
app.Use(allow(db, listed))
|
||||
terminal := func(c *zip.Ctx) error {
|
||||
c.SetHeader("Vary", "Accept-Encoding")
|
||||
return c.String(http.StatusOK, "ok")
|
||||
}
|
||||
for p := range browserPaths {
|
||||
app.Get(p, terminal)
|
||||
app.Post(p, terminal)
|
||||
}
|
||||
|
||||
return func(method, path, origin string) probe {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
if method == http.MethodOptions {
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
}
|
||||
res, err := app.Test(req, zip.TestConfig{Timeout: 0, FailOnTimeout: false})
|
||||
if err != nil {
|
||||
// The transport refused to parse the header (a control character, say).
|
||||
// The request never reached the middleware, so nothing was echoed —
|
||||
// which is the same miss, arrived at one layer earlier.
|
||||
return probe{status: http.StatusBadRequest}
|
||||
}
|
||||
defer res.Body.Close()
|
||||
return probe{
|
||||
status: res.StatusCode,
|
||||
origin: res.Header.Get("Access-Control-Allow-Origin"),
|
||||
credentials: res.Header.Get("Access-Control-Allow-Credentials"),
|
||||
vary: res.Header.Get("Vary"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// every path under test, credentialed and read alike.
|
||||
func allPaths() []string { return append(append([]string{}, credentialed...), readPath) }
|
||||
|
||||
// THE SHIPPED LOGINS. All five sites the SDK sends with credentials must answer
|
||||
// a listed console with BOTH the echoed origin and the credential allowance, on
|
||||
// the preflight AND on the actual response. A browser drops a
|
||||
// credentials:"include" response that lacks either — so a gate written as a pure
|
||||
// removal signs every console out of every brand.
|
||||
func TestTheFiveCredentialedSitesKeepWorkingForAListedConsole(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, path := range credentialed {
|
||||
pre := do(http.MethodOptions, path, ours)
|
||||
if pre.origin != ours || pre.credentials != "true" {
|
||||
t.Errorf("preflight %s: allow-origin=%q credentials=%q, want the origin echoed with credentials",
|
||||
path, pre.origin, pre.credentials)
|
||||
}
|
||||
if pre.status != http.StatusNoContent {
|
||||
t.Errorf("preflight %s: status %d, want 204", path, pre.status)
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||
got := do(method, path, ours)
|
||||
if got.origin != ours || got.credentials != "true" {
|
||||
t.Errorf("%s %s: allow-origin=%q credentials=%q, want the origin echoed with credentials",
|
||||
method, path, got.origin, got.credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE VULNERABILITY, in the shape that was actually reachable. These origins are
|
||||
// SAME-SITE with the IdP host, so the browser WILL attach the SSO cookie; the
|
||||
// only thing between them and a signed-in user's account is this middleware
|
||||
// declining to name them. They must get no Access-Control-Allow-Origin header at
|
||||
// all — not the origin echoed back, not a wildcard — and above all no credential
|
||||
// allowance on the login endpoint, which mints an authorization code from that
|
||||
// cookie.
|
||||
func TestSameSiteCustomerContentOriginGetsNothing(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, origin := range sameSite {
|
||||
for _, path := range allPaths() {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
|
||||
got := do(method, path, origin)
|
||||
if got.origin != "" {
|
||||
t.Errorf("%s %s from same-site %q echoed Allow-Origin %q — customer-published "+
|
||||
"content is not a first-party console", method, path, origin, got.origin)
|
||||
}
|
||||
if got.credentials != "" {
|
||||
t.Errorf("%s %s from same-site %q allowed credentials — this is the "+
|
||||
"account-takeover path", method, path, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A hostile CROSS-site origin gets the same nothing. It could not spend the Lax
|
||||
// cookie even if it were echoed, which is exactly why it must not be echoed: the
|
||||
// grant must not depend on a cookie attribute a future change could relax.
|
||||
func TestHostileOriginGetsNoHeaderAtAll(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, path := range allPaths() {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
|
||||
got := do(method, path, hostile)
|
||||
if got.origin != "" {
|
||||
t.Errorf("%s %s from a hostile origin echoed Allow-Origin %q", method, path, got.origin)
|
||||
}
|
||||
if got.credentials != "" {
|
||||
t.Errorf("%s %s from a hostile origin allowed credentials", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attacks are every near-miss of a real console origin an attacker can put in an
|
||||
// Origin header, plus the parser tricks that turn a sloppy comparison into a
|
||||
// match. Exact equality admits none of them; a suffix, prefix, contains,
|
||||
// case-folded or "parse it and compare only the host" check admits at least one.
|
||||
func attacks() []string {
|
||||
var out []string
|
||||
for _, base := range []string{"console.hanzo.ai", "hanzo.ai"} {
|
||||
out = append(out,
|
||||
// The brand as a PREFIX of the attacker's own host.
|
||||
"https://"+base+".evil.com",
|
||||
"https://"+base+".evil.com:443",
|
||||
"https://"+base+"-evil.com",
|
||||
"https://"+base+"%2eevil.com",
|
||||
// The brand as a SUFFIX of the attacker's own host — no dot boundary.
|
||||
"https://evil"+base,
|
||||
"https://evil-"+base,
|
||||
"https://x"+base,
|
||||
"https://."+base,
|
||||
// Case.
|
||||
"https://"+strings.ToUpper(base),
|
||||
"https://"+strings.ToUpper(base[:1])+base[1:],
|
||||
// Trailing dot: resolves the same, different origin and cookie scope.
|
||||
"https://"+base+".",
|
||||
"https://"+base+".:443",
|
||||
// Ports.
|
||||
"https://"+base+":8443",
|
||||
"https://"+base+":443",
|
||||
"https://"+base+":0",
|
||||
"https://"+base+":",
|
||||
// Scheme.
|
||||
"http://"+base,
|
||||
"HTTPS://"+base,
|
||||
"Https://"+base,
|
||||
"ftp://"+base,
|
||||
"ws://"+base,
|
||||
"wss://"+base,
|
||||
"//"+base,
|
||||
base,
|
||||
// Not a bare serialized origin any more.
|
||||
"https://"+base+"/",
|
||||
"https://"+base+"/callback",
|
||||
"https://"+base+"?a=b",
|
||||
"https://"+base+"#f",
|
||||
"https://user@"+base,
|
||||
"https://user:pass@"+base,
|
||||
"https://"+base+"\\@evil.com",
|
||||
"https://"+base+"\x00",
|
||||
// Header injection: the transport FOLDS a CRLF into the value rather
|
||||
// than splitting it, so the smuggled field arrives inside the Origin
|
||||
// string and only the reconstruct-and-compare stops it being echoed.
|
||||
"https://"+base+"\r\nX-Injected: 1",
|
||||
"https://"+base+"\r\n\r\n<script>",
|
||||
"https://"+base+"%0d%0aX-Injected:%201",
|
||||
"https://"+base+"\r\nAccess-Control-Allow-Credentials: true",
|
||||
// Two origins in one header.
|
||||
"https://"+base+" https://evil.example.com",
|
||||
"https://"+base+",https://evil.example.com",
|
||||
"https://evil.example.com,https://"+base,
|
||||
// Encoded and unicode confusables.
|
||||
"https://%63onsole.hanzo.ai",
|
||||
"https://"+base+"",
|
||||
"https://"+strings.Replace(base, "a", "а", 1), // cyrillic а
|
||||
// Wildcards an operator might have meant.
|
||||
"https://*."+base,
|
||||
"*."+base,
|
||||
"*",
|
||||
)
|
||||
}
|
||||
return append(out,
|
||||
"null",
|
||||
"",
|
||||
" ",
|
||||
"undefined",
|
||||
"file://",
|
||||
"data:text/html,x",
|
||||
"https://",
|
||||
"https://:443",
|
||||
"https://[::1]",
|
||||
"https://127.0.0.1",
|
||||
"https://localhost",
|
||||
"http://localhost:3000",
|
||||
"https://hanzo.ai.evil.com",
|
||||
"https://evil-hanzo.ai",
|
||||
"https://hanzoai.ai",
|
||||
"https://hanzo.a",
|
||||
"https://hanzo.aii",
|
||||
)
|
||||
}
|
||||
|
||||
// Every attack string, on the most dangerous path there is. None may be echoed
|
||||
// and none may carry a credential.
|
||||
func TestParserAttacksAreAllMisses(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true, "https://hanzo.ai": true})
|
||||
list := attacks()
|
||||
if len(list) < 70 {
|
||||
t.Fatalf("the attack corpus shrank to %d; it is the regression net", len(list))
|
||||
}
|
||||
for _, o := range list {
|
||||
for _, path := range []string{"/v1/iam/login", readPath} {
|
||||
got := do(http.MethodPost, path, o)
|
||||
if got.origin != "" || got.credentials != "" {
|
||||
t.Errorf("%s: origin %q was admitted (allow-origin=%q credentials=%q); it must be a miss",
|
||||
path, o, got.origin, got.credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WHITESPACE IS THE TRANSPORT'S JOB, NOT OURS — asserted, because the middleware
|
||||
// deliberately does NOT trim and a reviewer will ask why.
|
||||
//
|
||||
// RFC 9110 §5.5 says leading and trailing OWS is not part of a field value, and
|
||||
// the HTTP parser strips it before any handler runs (verified: "https://x ",
|
||||
// " https://x", "https://x\t" and "https://x\n" all reach the middleware as
|
||||
// "https://x"). So a padded header IS the canonical origin by the time we see it,
|
||||
// the value echoed back is canonical, and there is nothing left to smuggle. A
|
||||
// trim in this package would be a second normalisation rule carved out beside
|
||||
// exact(), which is the total one.
|
||||
func TestPaddedOriginIsCanonicalisedByTheTransportNotByUs(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, padded := range []string{ours + " ", " " + ours, ours + "\t", ours + "\n", "\t" + ours + " "} {
|
||||
got := do(http.MethodPost, "/v1/iam/login", padded)
|
||||
if got.origin != ours {
|
||||
t.Errorf("Origin %q: allow-origin = %q, want the canonical %q — the transport strips OWS",
|
||||
padded, got.origin, ours)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HEADER INJECTION through the echoed origin. A CRLF is FOLDED into the field
|
||||
// value by the transport rather than splitting it, so the smuggled field arrives
|
||||
// as part of the Origin string — and the only thing that stops it being written
|
||||
// back into the response is exact() refusing anything that is not already its own
|
||||
// canonical serialization. Nothing may be echoed, and no smuggled header may
|
||||
// appear.
|
||||
func TestACRLFInTheOriginIsNeverEchoedBack(t *testing.T) {
|
||||
_ = schema.Kinds()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(t.TempDir(), "cors.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
app := zip.New(zip.Config{AppName: "cors-injection", DisableStartupMessage: true})
|
||||
app.Use(allow(db, consoles{ours: true}))
|
||||
app.Post("/v1/iam/login", func(c *zip.Ctx) error { return c.String(http.StatusOK, "ok") })
|
||||
|
||||
for _, o := range []string{
|
||||
ours + "\r\nX-Injected: 1",
|
||||
ours + "\r\nAccess-Control-Allow-Credentials: true",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/iam/login", nil)
|
||||
req.Header.Set("Origin", o)
|
||||
res, err := app.Test(req, zip.TestConfig{Timeout: 0, FailOnTimeout: false})
|
||||
if err != nil {
|
||||
continue // the transport refused it outright; the same miss, one layer earlier
|
||||
}
|
||||
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Origin %q was echoed as %q", o, got)
|
||||
}
|
||||
if got := res.Header.Get("X-Injected"); got != "" {
|
||||
t.Errorf("Origin %q smuggled X-Injected: %q into the response", o, got)
|
||||
}
|
||||
if got := res.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("Origin %q smuggled Allow-Credentials: %q into the response", o, got)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// LEAST PRIVILEGE, and the crown jewel. A listed console may sign a user in and
|
||||
// out; it may NOT read the account object with the ambient cookie. get-account is
|
||||
// exactly what the live proxy defect disclosed, so it stays readable only by a
|
||||
// caller holding a Bearer token.
|
||||
func TestListedConsoleStillCannotReadTheAccountWithTheCookie(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, path := range []string{
|
||||
readPath, "/v1/iam/oauth/userinfo", "/v1/iam/get-users",
|
||||
"/v1/iam/get-organizations", "/v1/iam/oauth/token",
|
||||
} {
|
||||
got := do(http.MethodGet, path, ours)
|
||||
if got.credentials != "" {
|
||||
t.Errorf("%s allowed credentials for a listed console (%q); a read must never be "+
|
||||
"answerable from the SSO cookie cross-origin", path, got.credentials)
|
||||
}
|
||||
if got.origin != ours {
|
||||
t.Errorf("%s allow-origin = %q, want the console echoed (a Bearer read is still allowed)",
|
||||
path, got.origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two lists answer two different questions. A tenant that registers a
|
||||
// redirect_uri on a host it controls lands in the DERIVED set — it may read a
|
||||
// PKCE answer, and it must never thereby be able to spend the user's cookie.
|
||||
func TestRegisteredTenantReadsButNeverCarriesTheCookie(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
if got := do(http.MethodPost, "/v1/iam/oauth/token", theirs); got.origin != theirs {
|
||||
t.Errorf("a registered redirect origin was refused the token exchange: allow-origin=%q", got.origin)
|
||||
}
|
||||
for _, path := range allPaths() {
|
||||
got := do(http.MethodPost, path, theirs)
|
||||
if got.credentials != "" {
|
||||
t.Errorf("%s: a merely REGISTERED origin was allowed credentials — the derived allowlist "+
|
||||
"is tenant-writable, so this hands every signed-in user's session to a tenant", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list is the behaviour that predates it: nothing carries the cookie.
|
||||
// Configuration widens the grant; it is never assumed.
|
||||
func TestUnsetListGrantsNoCredentials(t *testing.T) {
|
||||
do := harness(t, nil)
|
||||
for _, path := range credentialed {
|
||||
if got := do(http.MethodPost, path, ours); got.credentials != "" {
|
||||
t.Errorf("%s: credentials allowed with an unset list: %q", path, got.credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vary: Origin must ride EVERY answer on a browser path, including the refusals
|
||||
// and the no-Origin request. A Vary set only on the allowed branch lets a shared
|
||||
// cache learn "this URL is readable by anyone" from one console's request and
|
||||
// replay it to the next origin — the cache-poisoning half of this bug.
|
||||
func TestVaryOnOriginRidesEveryAnswer(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, o := range []string{ours, theirs, hostile, "https://zzz.hanzo.app", "https://console.hanzo.ai.", ""} {
|
||||
for _, path := range allPaths() {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodOptions} {
|
||||
got := do(method, path, o)
|
||||
if !varies(got.vary, "Origin") {
|
||||
t.Errorf("%s %s from %q: Vary = %q, want it to include Origin", method, path, o, got.vary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE CLOBBER. The terminal handler sets its own Vary, which is what a real
|
||||
// handler does on any negotiated response. A Vary written BEFORE c.Next() is
|
||||
// simply replaced by it and the cache protection silently disappears — the
|
||||
// response looks correct in a unit test that never runs a handler. Both fields
|
||||
// must survive, and Origin must appear exactly once.
|
||||
func TestVarySurvivesAHandlerThatSetsItsOwnVary(t *testing.T) {
|
||||
do := harness(t, consoles{ours: true})
|
||||
for _, o := range []string{ours, hostile, ""} {
|
||||
got := do(http.MethodGet, "/v1/iam/login", o)
|
||||
if !varies(got.vary, "Origin") {
|
||||
t.Errorf("from %q: Vary = %q — the handler's own Vary clobbered ours", o, got.vary)
|
||||
}
|
||||
if !varies(got.vary, "Accept-Encoding") {
|
||||
t.Errorf("from %q: Vary = %q — we clobbered the handler's", o, got.vary)
|
||||
}
|
||||
if strings.Count(strings.ToLower(got.vary), "origin") != 1 {
|
||||
t.Errorf("from %q: Vary = %q — Origin listed more than once", o, got.vary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// varies reports whether field is one of the comma-separated Vary members.
|
||||
func varies(header, field string) bool {
|
||||
for _, f := range strings.Split(header, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(f), field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Config parsing. A suffix, a bare domain or a wildcard is an ERROR, not a
|
||||
// silently dropped entry: this fleet serves *.hanzo.app as customer-published
|
||||
// sites, so a suffix read of a brand list would name every customer site a
|
||||
// first-party console.
|
||||
func TestParseRefusesAnythingThatIsNotAnExactHTTPSOrigin(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"hanzo.ai", // bare domain
|
||||
".hanzo.ai", // suffix
|
||||
"*.hanzo.ai", // wildcard
|
||||
"https://*.hanzo.ai", // wildcard with a scheme
|
||||
"http://console.hanzo.ai", // plaintext
|
||||
"https://console.hanzo.ai/", // trailing slash
|
||||
"https://console.hanzo.ai/path", // carries a path
|
||||
"https://u:p@console.hanzo.ai", // userinfo
|
||||
"https://console.hanzo.ai?a=b", // query
|
||||
"https://console.hanzo.ai.", // trailing dot
|
||||
"https://console..hanzo.ai", // empty label
|
||||
"console.hanzo.ai:443", // no scheme
|
||||
"*", // the wildcard that would end the world
|
||||
"null",
|
||||
} {
|
||||
if _, err := parse(bad); err == nil {
|
||||
t.Errorf("parse(%q) was accepted; a malformed entry must fail the boot loud", bad)
|
||||
}
|
||||
}
|
||||
// And one bad entry among good ones still fails: a partial parse would deny
|
||||
// exactly one brand its login while the rest kept working.
|
||||
if _, err := parse("https://console.hanzo.ai,*.hanzo.app,https://cloud.lux.network"); err == nil {
|
||||
t.Error("a list with one bad entry parsed; it must fail the boot loud")
|
||||
}
|
||||
}
|
||||
|
||||
// What an operator legitimately writes must parse, including several brands in
|
||||
// one list and a capitalisation a browser would send lower-cased.
|
||||
func TestParseAcceptsTheRealConsoleList(t *testing.T) {
|
||||
set, err := parse(" https://console.hanzo.ai, https://Cloud.Lux.Network ,https://cloud.zoo.network, ")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"https://console.hanzo.ai", "https://cloud.lux.network", "https://cloud.zoo.network",
|
||||
} {
|
||||
if !set.has(want) {
|
||||
t.Errorf("%s missing from %v", want, set)
|
||||
}
|
||||
}
|
||||
if len(set) != 3 {
|
||||
t.Errorf("set = %v, want exactly the three listed origins", set)
|
||||
}
|
||||
if empty, err := parse(""); err != nil || len(empty) != 0 {
|
||||
t.Errorf("an unset list must parse to the empty set, got %v, %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie surface is a security decision, so it is asserted rather than
|
||||
// assumed: exactly the five sites hanzoai/js-iam sends `credentials: "include"`
|
||||
// to, and nothing that answers a READ.
|
||||
func TestCookieSurfaceIsExactlyTheShippedSDKsCredentialedSites(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for p, mode := range browserPaths {
|
||||
if mode == cookie {
|
||||
got[p] = true
|
||||
}
|
||||
}
|
||||
for _, p := range credentialed {
|
||||
if !got[p] {
|
||||
t.Errorf("%s must admit the cookie: hanzoai/js-iam sends it with credentials, and a "+
|
||||
"browser discards a credentialed response that does not allow the credential", p)
|
||||
}
|
||||
delete(got, p)
|
||||
}
|
||||
for p := range got {
|
||||
t.Errorf("%s admits the cookie but no shipped client sends credentials to it", p)
|
||||
}
|
||||
for _, p := range []string{
|
||||
"/v1/iam/get-account", "/v1/iam/oauth/userinfo", "/v1/iam/get-users",
|
||||
"/v1/iam/get-organizations", "/v1/iam/oauth/token", "/v1/iam/organizations",
|
||||
"/v1/iam/invitations", "/v1/iam/get-organization",
|
||||
} {
|
||||
if browserPaths[p] == cookie {
|
||||
t.Errorf("%s must NOT admit the cookie: it answers a READ, which is the disclosure this closes", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package cred verifies a stored password digest against a plaintext, resolving
|
||||
// the algorithm FROM THE STORED ROW — never from a constant.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cred
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cred
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package e2e_test drives the WHOLE iam surface through the real registered router
|
||||
// (routes.Route) as one integrated journey — the behavioral parity proof that the
|
||||
@@ -33,8 +34,8 @@ import (
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
@@ -78,7 +79,9 @@ func boot(t *testing.T) *env {
|
||||
|
||||
app := zip.New(zip.Config{AppName: "iam-e2e", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &env{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
@@ -223,7 +226,7 @@ func (e *env) login(t *testing.T, verifier string) string {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "code", "organization": "hanzo", "username": "alice@hanzo.ai", "password": "pw",
|
||||
"clientId": "hanzo-console", "redirectUri": redirectURI, "scope": "openid profile email offline_access",
|
||||
"codeChallenge": oidc.ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
|
||||
if st != 200 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package featurestore implements feature.Store over the iam orm store, so the
|
||||
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package featurestore
|
||||
|
||||
|
||||
+113
-19
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package httpx is the shared HTTP layer for the IAM v2 handlers: the
|
||||
// the legacy surface-compatible Response envelope that the @hanzo/iam SDK and the hanzo.id
|
||||
@@ -16,9 +17,17 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Response is the the legacy surface-compatible envelope. status is "ok" or "error"; a
|
||||
// non-ok status rides on a 200 (every SDK branches on status, not the HTTP
|
||||
// code — preserving that contract keeps the clients unchanged at cutover).
|
||||
// Response is the the legacy surface-compatible envelope. status is "ok" or
|
||||
// "error", and it stays the field an SDK branches on for the REASON a call
|
||||
// failed. The HTTP status says whether it failed at all, and the two agree:
|
||||
// a refusal is a 4xx carrying status:"error".
|
||||
//
|
||||
// It used to ride on a 200. That inherited the upstream's habit of using the
|
||||
// envelope as the only channel, and it made every refusal indistinguishable from
|
||||
// a success to the layer that checks first — `res.ok` in fetch,
|
||||
// `raise_for_status()` in requests, `StatusCode/100 == 2` in Go. A signup that was
|
||||
// refused therefore READ as a signup that had happened, and the caller went on to
|
||||
// the next step of an onboarding that did not exist.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
@@ -49,43 +58,128 @@ func ServiceToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServiceTokenAuth reports whether the request carries the unified service token as
|
||||
// a Bearer credential, compared in constant time. An unset expected token, or any
|
||||
// ServiceAuth reports whether an `Authorization` header VALUE carries the unified
|
||||
// service token, compared in constant time. An unset expected token, or any
|
||||
// mismatch, is false — fail closed: no token configured means no service surface.
|
||||
func ServiceTokenAuth(c *zip.Ctx) bool {
|
||||
//
|
||||
// It takes the header rather than the request because a TYPED op never sees a
|
||||
// *zip.Ctx: the credential arrives on its input, declared `header:"Authorization"`,
|
||||
// and the check has to run on that value. So this is the ONE implementation and
|
||||
// ServiceTokenAuth is the same check on a raw handler's request — the same split
|
||||
// as Good/Bad against Ok/Fail below, a value and a place.
|
||||
func ServiceAuth(h string) bool {
|
||||
expected := ServiceToken()
|
||||
if expected == "" {
|
||||
return false
|
||||
}
|
||||
got := Bearer(c)
|
||||
got := token(h)
|
||||
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
// ServiceTokenAuth reports whether the request carries the unified service token as
|
||||
// a Bearer credential.
|
||||
func ServiceTokenAuth(c *zip.Ctx) bool { return ServiceAuth(c.Header("Authorization")) }
|
||||
|
||||
// Answer is a Response together with the status it rides on — the envelope as a
|
||||
// VALUE, for a handler that returns its reply instead of writing it.
|
||||
//
|
||||
// A typed op is a function, so its answer has to BE a value: zip renders what the
|
||||
// handler returns and there is no *zip.Ctx to write through. The status has to
|
||||
// ride with it because this envelope's whole contract is that the two agree — a
|
||||
// refusal is a 4xx carrying status:"error" — and a typed op that returned a bare
|
||||
// Response would answer every refusal 200 and break exactly that.
|
||||
//
|
||||
// The wire shape is Response's and only Response's: the embedding promotes its
|
||||
// fields, `code` is unexported, so an Answer and the Response inside it marshal
|
||||
// to the same bytes. One envelope, two ways of holding it, no second shape to
|
||||
// keep in sync.
|
||||
//
|
||||
// It is a distinct type rather than a method on Response because zip reads
|
||||
// [zip.StatusCoder] off the value an op returns and refuses any status the op did
|
||||
// not declare with zip.WithStatus. Response is already returned by typed ops that
|
||||
// declare none (internal/compat), so teaching Response to state a status would
|
||||
// make every one of them answer a status zip then refuses.
|
||||
type Answer struct {
|
||||
Response
|
||||
code int
|
||||
}
|
||||
|
||||
// StatusCode is [zip.StatusCoder]: the status this answer rides on. Zero means
|
||||
// the answer never named one, and 200 is what an unnamed answer has always been.
|
||||
func (a *Answer) StatusCode() int {
|
||||
if a.code == 0 {
|
||||
return 200
|
||||
}
|
||||
return a.code
|
||||
}
|
||||
|
||||
// Good is the 200 { status:"ok", data } envelope. The success half of the pair,
|
||||
// as a value.
|
||||
func Good(data any, more ...any) *Answer {
|
||||
a := &Answer{Response: Response{Status: "ok", Data: data}, code: 200}
|
||||
if len(more) > 0 {
|
||||
a.Data2 = more[0]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Bad is the { status:"error", msg, code } envelope under the status that
|
||||
// matches it. The refusal half of the pair, as a value.
|
||||
func Bad(status int, msg, code string) *Answer {
|
||||
return &Answer{Response: Response{Status: "error", Msg: msg, Code: code}, code: status}
|
||||
}
|
||||
|
||||
// Ok writes 200 { status:"ok", data }.
|
||||
func Ok(c *zip.Ctx, data any, more ...any) error {
|
||||
r := Response{Status: "ok", Data: data}
|
||||
if len(more) > 0 {
|
||||
r.Data2 = more[0]
|
||||
}
|
||||
return c.JSON(200, r)
|
||||
return write(c, Good(data, more...))
|
||||
}
|
||||
|
||||
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
|
||||
// not HTTP code).
|
||||
// Fail writes { status:"error", msg, code } under an HTTP status that MATCHES it.
|
||||
// ONE implementation writes the error envelope; everything below names a status
|
||||
// for it, and nothing else in this package may write one.
|
||||
func Fail(c *zip.Ctx, status int, msg, code string) error {
|
||||
return write(c, Bad(status, msg, code))
|
||||
}
|
||||
|
||||
// write sends an Answer through a raw handler's Ctx. Unexported: a typed op
|
||||
// RETURNS its answer and never needs this, so the only callers are the two
|
||||
// writers above — which is what makes Good/Bad the one place each variant of the
|
||||
// envelope is built, whether it is returned or written.
|
||||
func write(c *zip.Ctx, a *Answer) error {
|
||||
return c.JSON(a.StatusCode(), a.Response)
|
||||
}
|
||||
|
||||
// Err writes a refusal the CALLER can act on: bad input, a credential we would
|
||||
// not take, a name already spoken for. 400 is the honest default for this
|
||||
// surface — these are front-door validation and authentication failures, and the
|
||||
// caller is the one holding the thing that was wrong. A handler that knows better
|
||||
// says so by calling Fail with the status it means.
|
||||
func Err(c *zip.Ctx, msg string) error {
|
||||
return ErrCode(c, msg, "")
|
||||
}
|
||||
|
||||
// ErrCode is Err carrying a machine-readable reason alongside the human message.
|
||||
// ONE implementation writes the error envelope; Err is this with no reason to give.
|
||||
func ErrCode(c *zip.Ctx, msg, code string) error {
|
||||
return c.JSON(200, Response{Status: "error", Msg: msg, Code: code})
|
||||
return Fail(c, 400, msg, code)
|
||||
}
|
||||
|
||||
// A note on 401. Several refusals here are authentication failures ("please sign
|
||||
// in first", CodeLoginRequired) and 401 is their honest status. They are NOT
|
||||
// spelled that way, deliberately: these handlers sit on the PRE-GUARD group, and
|
||||
// the Guard's own refusal is a 401 too, so a handler that answered 401 would
|
||||
// become indistinguishable from a route that was never public — which is exactly
|
||||
// what internal/authz's public-route tests assert on. Separating those two needs
|
||||
// the Guard to be told apart from a handler by something other than the status,
|
||||
// which is a change to the authz surface and not to this envelope. Until then the
|
||||
// machine-readable `code` carries the distinction, which is what it is for.
|
||||
|
||||
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
|
||||
func Bearer(c *zip.Ctx) string {
|
||||
func Bearer(c *zip.Ctx) string { return token(c.Header("Authorization")) }
|
||||
|
||||
// token is the credential an `Authorization: Bearer <token>` header VALUE carries,
|
||||
// or "". The parse lives here once, for the request half and the value half alike.
|
||||
func token(h string) string {
|
||||
const p = "Bearer "
|
||||
h := c.Header("Authorization")
|
||||
if len(h) > len(p) && h[:len(p)] == p {
|
||||
return h[len(p):]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package invitations serves the IAM v2 CRUD surface for the `invitations`
|
||||
// entity: a pending org-membership invite owner-scoped by (owner, name). Every
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package keys serves the owner-scoped CRUD surface for the `keys` entity
|
||||
// (v1 the legacy surface `key`) as typed zip handlers over hanzoai/orm.
|
||||
@@ -309,9 +310,8 @@ func NameFor(scope string) string {
|
||||
//
|
||||
// It writes a schema.Key row because that is the ONLY thing the resolvers read. The
|
||||
// previous implementation stamped the sk- onto schema.User.AccessKey, which NOTHING
|
||||
// resolves: every key minted that way authenticated nobody, and because it overwrote
|
||||
// the user's working legacy hk- in the same field it locked the holder out with no
|
||||
// way back through the UI. Writing the row the resolver actually reads is the fix.
|
||||
// resolves: every key minted that way authenticated nobody. Writing the row the
|
||||
// resolver actually reads is the fix.
|
||||
//
|
||||
// Idempotent by (Owner, NameFor(scope)): re-minting replaces the credential in place.
|
||||
func MintUserKey(ctx context.Context, db orm.DB, owner, user, scope string) (string, error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package keys
|
||||
|
||||
@@ -129,9 +130,7 @@ func TestKeys_PublishableForcesSecretEmpty(t *testing.T) {
|
||||
//
|
||||
// It did not. mintUserKeysHandler stamped the sk- onto schema.User.AccessKey, while
|
||||
// store.UserByAccessKey's sk- branch reads schema.Key.AccessSecret — the write and
|
||||
// the read never met, so every minted key authenticated nobody. Worse, the write
|
||||
// landed in the same field as the user's working legacy hk-, so regenerating a key
|
||||
// locked the holder out with no way back through the UI.
|
||||
// the read never met, so every minted key authenticated nobody.
|
||||
func TestMintUserKey_ResolvesBackToItsUser(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package memberships serves the (User × Org × Role) tenancy relation — which
|
||||
// orgs an identity may act in, and with what coarse role. It is the set a token
|
||||
@@ -46,6 +47,8 @@ const (
|
||||
// unauthorized is v1's refusal message, verbatim.
|
||||
const unauthorized = "auth:Unauthorized operation"
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the membership surface on app, backed by db: the native REST
|
||||
// pair plus the legacy verb aliases. get/add share the REST handlers (one authz
|
||||
// gate, one store call, no duplication); delete adds the revoke the REST face does
|
||||
@@ -53,15 +56,42 @@ const unauthorized = "auth:Unauthorized operation"
|
||||
// handler-authorized (authz.handlerAuthorizedPrefixes) exactly like /v1/iam/
|
||||
// memberships — the list handler's own scoped() check is the tenant gate; the two
|
||||
// write verbs are POSTs the Guard never pre-authorizes, so each self-authorizes.
|
||||
//
|
||||
// The two READS are typed ops, so both addresses are in the OpenAPI document, the
|
||||
// SDKs, the CLI and the MCP tool list. NEITHER names an operationId: what
|
||||
// distinguishes them IS the address, so the address names them (zip's path-derived
|
||||
// default), and a hand-picked id would collide — one operationId, one operation.
|
||||
// The writes stay raw: typing them would newly route them through the op-invoke
|
||||
// authorizer on a decoded (Owner, Name) their bodies do not carry, changing who
|
||||
// may grant. That is a decision, not a projection.
|
||||
//
|
||||
// A typed read still reaches that authorizer, and is admitted by construction: it
|
||||
// admits a GET whose decoded input names no owner, and `lookup` declares no Owner
|
||||
// field and no AuthzTarget() for it to read. scoped() remains the whole tenant
|
||||
// gate. A refusal is a VALUE (httpx.Bad), never a returned error — an error
|
||||
// renders zip's {"status":<int>,"error":…} instead of this surface's envelope.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
app.Get(Path, list(db))
|
||||
zip.Get[lookup, httpx.Answer](app, Path, list(db),
|
||||
zip.WithStatus(200, 400),
|
||||
zip.WithTags("memberships"))
|
||||
app.Post(Path, ensure(db))
|
||||
|
||||
app.Get(PathGet, list(db))
|
||||
zip.Get[lookup, httpx.Answer](app, PathGet, list(db),
|
||||
zip.WithStatus(200, 400),
|
||||
zip.WithTags("memberships"))
|
||||
app.Post(PathAdd, ensure(db))
|
||||
app.Post(PathDelete, remove(db))
|
||||
}
|
||||
|
||||
// lookup is the list request: exactly one of the identity whose organizations are
|
||||
// wanted, or the organization whose roster is.
|
||||
type lookup struct {
|
||||
// User is "<homeOrg>/<username>" — which organizations that identity may act in.
|
||||
User string `json:"user"`
|
||||
// Org is an organization — who may act in it.
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
// request is the ensure body.
|
||||
type request struct {
|
||||
User string `json:"user"` // "<homeOrg>/<username>"
|
||||
@@ -77,30 +107,26 @@ type request struct {
|
||||
// the verified credential via authz.Scope, so a request parameter can never
|
||||
// widen it — a membership row names who may act and spend in an org, so a
|
||||
// cross-tenant read is a customer roster leak.
|
||||
func list(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
user, org := c.Query("user"), c.Query("org")
|
||||
if (user == "") == (org == "") {
|
||||
return httpx.Err(c, "exactly one of user or org is required")
|
||||
func list(db orm.DB) zip.TypedHandler[lookup, httpx.Answer] {
|
||||
return func(ctx context.Context, in *lookup) (*httpx.Answer, error) {
|
||||
if (in.User == "") == (in.Org == "") {
|
||||
return httpx.Bad(400, "exactly one of user or org is required", ""), nil
|
||||
}
|
||||
if org != "" {
|
||||
if !scoped(ctx, org) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
if in.Org != "" {
|
||||
if !scoped(ctx, in.Org) {
|
||||
return httpx.Bad(400, unauthorized, ""), nil
|
||||
}
|
||||
rows, err := store.MembershipsByOrg(ctx, db, org)
|
||||
return listed(c, rows, err)
|
||||
return listed(store.MembershipsByOrg(ctx, db, in.Org))
|
||||
}
|
||||
// A user id is "<homeOrg>/<name>": its home org is the tenant bound here.
|
||||
home, _, found := strings.Cut(user, "/")
|
||||
home, _, found := strings.Cut(in.User, "/")
|
||||
if !found || home == "" {
|
||||
return httpx.Err(c, "user must be <owner>/<name>")
|
||||
return httpx.Bad(400, "user must be <owner>/<name>", ""), nil
|
||||
}
|
||||
if !scoped(ctx, home) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
return httpx.Bad(400, unauthorized, ""), nil
|
||||
}
|
||||
rows, err := store.MembershipsByUser(ctx, db, user)
|
||||
return listed(c, rows, err)
|
||||
return listed(store.MembershipsByUser(ctx, db, in.User))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,10 +222,11 @@ func scoped(ctx context.Context, org string) bool {
|
||||
return err == nil && got == org
|
||||
}
|
||||
|
||||
// listed writes a membership listing, or the error envelope on failure.
|
||||
func listed(c *zip.Ctx, rows []*schema.Membership, err error) error {
|
||||
// listed answers a membership listing, or the error envelope on failure. It takes
|
||||
// the store call's pair so the two branches of list read as one line each.
|
||||
func listed(rows []*schema.Membership, err error) (*httpx.Answer, error) {
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
return httpx.Bad(400, err.Error(), ""), nil
|
||||
}
|
||||
return c.JSON(200, httpx.Response{Status: "ok", Data: rows, Data2: len(rows)})
|
||||
return httpx.Good(rows, len(rows)), nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package memberships_test
|
||||
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -70,7 +72,9 @@ func newHarness(t *testing.T) *harness {
|
||||
|
||||
app := zip.New(zip.Config{AppName: "memberships-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
@@ -91,12 +95,8 @@ func (h *harness) token(t *testing.T, sub string) string {
|
||||
|
||||
func (h *harness) get(t *testing.T, path, bearer string) (int, env) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
return h.do(t, req)
|
||||
status, body := h.read(t, path, bearer)
|
||||
return status, envOf(body)
|
||||
}
|
||||
|
||||
func (h *harness) post(t *testing.T, path string, body any, bearer string) (int, env) {
|
||||
@@ -127,6 +127,22 @@ func (h *harness) postBasic(t *testing.T, path string, body any, clientID, secre
|
||||
// envelope. A raw 401 (the Guard's fail-closed refusal) has no envelope body; the
|
||||
// caller asserts on the status alone.
|
||||
func (h *harness) do(t *testing.T, req *http.Request) (int, env) {
|
||||
t.Helper()
|
||||
status, body := h.raw(t, req)
|
||||
return status, envOf(body)
|
||||
}
|
||||
|
||||
// envOf decodes the v1 envelope a body carries — the ONE decode, so `get` and
|
||||
// `do` cannot drift into reading the same bytes two ways.
|
||||
func envOf(body string) env {
|
||||
var e env
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
return e
|
||||
}
|
||||
|
||||
// raw is do without the decode — the status and the body VERBATIM, for a case
|
||||
// whose subject IS the bytes.
|
||||
func (h *harness) raw(t *testing.T, req *http.Request) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
@@ -134,9 +150,18 @@ func (h *harness) do(t *testing.T, req *http.Request) (int, env) {
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
var e env
|
||||
_ = json.Unmarshal(body, &e)
|
||||
return resp.StatusCode, e
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
// read drives one GET and returns the status and the body verbatim.
|
||||
func (h *harness) read(t *testing.T, url, bearer string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
return h.raw(t, req)
|
||||
}
|
||||
|
||||
// env is the v1 Response envelope the clients parse.
|
||||
@@ -294,6 +319,86 @@ func TestEnsureMembership_reservedOrgRequiresSuper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the read as a typed op ------------------------------------------------
|
||||
|
||||
// The list is a TYPED op at BOTH addresses, so it reaches two seams a raw handler
|
||||
// never did: zip's query binder, and the op-invoke authorizer (authz.Authorize).
|
||||
// Both are silent when they work and fatal when they do not — a binder that missed
|
||||
// ?org= answers "exactly one of user or org is required", an authorizer that saw a
|
||||
// target answers 403 — so these cases assert the RAW BODY BYTES at each address.
|
||||
//
|
||||
// The bytes are the point. Typing this read is a projection, not a change: same
|
||||
// address, same status, same envelope, before and after.
|
||||
func TestList_wire(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
|
||||
seedMembership(t, h.db, "hanzo/boss", "hanzo", store.RoleAdmin)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
|
||||
// Both addresses, one handler, one answer.
|
||||
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
status, body := h.read(t, path+"?org=hanzo", boss)
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s, want 200", status, body)
|
||||
}
|
||||
if !strings.HasPrefix(body, `{"status":"ok","msg":"","data":[`) || !strings.HasSuffix(body, `],"data2":2}`) {
|
||||
t.Fatalf("body=%s, want the v1 envelope with data2=2", body)
|
||||
}
|
||||
// The other question the same op answers: one identity's orgs.
|
||||
status, body = h.read(t, path+"?user=hanzo/alice", boss)
|
||||
if status != 200 || !strings.HasSuffix(body, `],"data2":1}`) {
|
||||
t.Fatalf("?user status=%d body=%s, want 200 with data2=1", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The refusals, byte for byte at both addresses: 400 carrying {status:"error",
|
||||
// msg, data:null}.
|
||||
func TestList_refusals(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss") // admin of hanzo, NOT of orgb
|
||||
const denied = `{"status":"error","msg":"auth:Unauthorized operation","data":null}`
|
||||
for _, c := range []struct{ name, query, want string }{
|
||||
{"neither", "", `{"status":"error","msg":"exactly one of user or org is required","data":null}`},
|
||||
{"both", "?user=hanzo/alice&org=hanzo", `{"status":"error","msg":"exactly one of user or org is required","data":null}`},
|
||||
// The angle brackets arrive escaped: encoding/json escapes HTML by
|
||||
// default, so the bytes carry the < form. The brackets are the
|
||||
// message's, the escaping is the encoder's, and the escaped form is what
|
||||
// this address has always put on the wire — assert the bytes, not the
|
||||
// message.
|
||||
{"unqualified user", "?user=alice", `{"status":"error","msg":"user must be \u003cowner\u003e/\u003cname\u003e","data":null}`},
|
||||
{"cross-tenant org", "?org=orgb", denied},
|
||||
{"cross-tenant user", "?user=orgb/bob", denied},
|
||||
} {
|
||||
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
|
||||
t.Run(c.name+" "+path, func(t *testing.T) {
|
||||
status, body := h.read(t, path+c.query, boss)
|
||||
if status != 400 || body != c.want {
|
||||
t.Fatalf("status=%d body=%s, want 400 %s", status, body, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The op-invoke authorizer admits this read because its input names no owner —
|
||||
// `lookup` declares no Owner field and no AuthzTarget(). An unknown query key is
|
||||
// therefore just an unknown query key: it is ignored by the binder and can never
|
||||
// become the target the authorizer decides on. Give the input an Owner field and
|
||||
// this is a 403, which is why the case is here rather than in a comment.
|
||||
func TestList_ownerQueryIsNotATarget(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
|
||||
for _, path := range []string{"/v1/iam/memberships", "/v1/iam/get-memberships"} {
|
||||
status, body := h.read(t, path+"?org=hanzo&owner=orgb&name=whatever", h.token(t, "hanzo/boss"))
|
||||
if status != 200 {
|
||||
t.Fatalf("%s status=%d body=%s, want 200 — the read is authorized by scoped(), not by ?owner=", path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verbs are gated: no bearer → the Guard fails closed (401).
|
||||
func TestMembershipVerbs_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
@@ -9,9 +9,19 @@ import (
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/get-memberships", zip.Doc{
|
||||
Description: "Answers either question about who belongs where: which organizations one\nperson can act in, or who can act in one organization.\n\nBoth are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or\nabout a user whose home org is its own, and nothing else. The bound comes from\nthe verified credential via authz.Scope, so a request parameter can never\nwiden it — a membership row names who may act and spend in an org, so a\ncross-tenant read is a customer roster leak.",
|
||||
Fields: map[string]string{
|
||||
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
|
||||
"lookup.org": "Org is an organization — who may act in it.",
|
||||
"lookup.user": "User is \"<homeOrg>/<username>\" — which organizations that identity may act in.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/iam/memberships", zip.Doc{
|
||||
Description: "Answers either question about who belongs where: which organizations one\nperson can act in, or who can act in one organization.\n\nBoth are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or\nabout a user whose home org is its own, and nothing else. The bound comes from\nthe verified credential via authz.Scope, so a request parameter can never\nwiden it — a membership row names who may act and spend in an org, so a\ncross-tenant read is a customer roster leak.",
|
||||
Fields: map[string]string{
|
||||
"Response.code": "Code is a STABLE machine-readable reason, where the human `msg` is\ndeliberately generic. `msg` is prose for a person and several distinct causes\nlegitimately share one sentence; a caller that must BRANCH on the cause — or\ntell its own user which of them happened — cannot parse prose. Optional, so\nevery existing envelope is byte-identical and no SDK changes.",
|
||||
"lookup.org": "Org is an organization — who may act in it.",
|
||||
"lookup.user": "User is \"<homeOrg>/<username>\" — which organizations that identity may act in.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-membership", zip.Doc{
|
||||
Description: "Lets a person or an application act in an organization. It is the grant\nbehind \"add someone to the team\", and it is safe to repeat — granting a\nmembership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the\nsame gate a write to that org's own registry row takes: a SuperAdmin, an admin\nof the org itself, or an org-admin-capable confidential client. One rule, one\nplace (internal/authz).",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package factor
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
// Package mfa serves the TOTP multi-factor enrollment surface — the account
|
||||
// security page's initiate → verify → enable flow (RFC 6238 TOTP), plus
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package mfa_test
|
||||
|
||||
@@ -69,7 +70,9 @@ func newHarness(t *testing.T) *harness {
|
||||
|
||||
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
|
||||
+108
-10
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -17,10 +18,22 @@ import (
|
||||
// of the authorization-code flow. iam validates the request BEFORE it trusts
|
||||
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
|
||||
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
|
||||
// and code-injection surface that a bare pass-through would leave open. A
|
||||
// well-formed request is delegated to the hosted login UI (matching v1), which
|
||||
// collects credentials and posts to /v1/iam/login; that endpoint mints the
|
||||
// PKCE-bound code and the browser lands back on the registered redirect_uri.
|
||||
// and code-injection surface that a bare pass-through would leave open.
|
||||
//
|
||||
// A validated request then has THREE possible answers, and which one it gets is
|
||||
// the whole of single sign-on:
|
||||
//
|
||||
// the session answers it — a code, straight back to the registered
|
||||
// redirect_uri, no screen (prompt.go)
|
||||
// nobody is signed in, and — error=login_required, back to the registered
|
||||
// the client said none redirect_uri, still no screen
|
||||
// otherwise — the hosted login UI, which collects credentials
|
||||
// and posts to /v1/iam/login
|
||||
//
|
||||
// Before this, only the third existed: every request rendered a login page,
|
||||
// prompt=none included. A relying party therefore had no way to ask "is anyone
|
||||
// signed in?" without putting a login screen in front of a user who already
|
||||
// was — which is not a missing feature, it is the absence of SSO.
|
||||
|
||||
// hostedLoginPath is the default hosted-login route the authorize endpoint hands
|
||||
// a validated request to when the application pins no SigninUrl of its own.
|
||||
@@ -39,20 +52,39 @@ type authorizeRequest struct {
|
||||
resource string
|
||||
responseMode string
|
||||
provider string
|
||||
prompt string
|
||||
}
|
||||
|
||||
// authorizeHandler starts a sign-in — the address you send a browser to, and the
|
||||
// beginning of every OAuth and OpenID Connect flow.
|
||||
//
|
||||
// It shows the person the right way to sign in for the application they are
|
||||
// signing in to, hands off to another identity provider if that is what they
|
||||
// pick, and ends by returning them to the application with a one-time code.
|
||||
// If the person is ALREADY signed in here, it does not ask them again: it
|
||||
// returns them to the application with a one-time code and they never see this
|
||||
// page. Otherwise it shows the right way to sign in for the application they are
|
||||
// signing in to, or hands off to another identity provider if that is what they
|
||||
// pick.
|
||||
//
|
||||
// A client can say what it wants with `prompt`: `none` means answer without any
|
||||
// screen at all — with the code if a session exists, with an error if not, but
|
||||
// never with a page; `login` means ask for the password again even if a session
|
||||
// exists; `select_account` means let the person choose which identity to use.
|
||||
//
|
||||
// It returns only to an address the application has registered. That check
|
||||
// happens before anything else, so a request naming an unregistered address is
|
||||
// refused where the person can see it rather than being bounced onwards.
|
||||
func authorizeHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
// A sign-in must run AT its brand's issuer, because everything a flow
|
||||
// sets along the way — the hanzo_fed browser binding, the session — is a
|
||||
// host-only cookie, while the IdP callback and `iss` are pinned to the
|
||||
// issuer. Answering on an alias host (iam.hanzo.ai, www.zoolabs.id, any
|
||||
// host the map folds) strands those cookies and social sign-in fails
|
||||
// closed at the callback. So an alias is answered with the same request
|
||||
// relocated to the issuer, before anything is minted or set; 307 keeps
|
||||
// the method. See issuerRelocation for the fail-closed guards.
|
||||
if loc, ok := issuerRelocation(c); ok {
|
||||
return c.Redirect(307, loc)
|
||||
}
|
||||
ctx := c.Context()
|
||||
q := authorizeParams(c)
|
||||
|
||||
@@ -87,16 +119,49 @@ func authorizeHandler(db orm.DB) zip.Handler {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "PKCE is required for public clients")
|
||||
}
|
||||
|
||||
p := parsePrompt(q.prompt)
|
||||
if p.combined {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "prompt=none must not be combined with other values")
|
||||
}
|
||||
|
||||
// A request that names a social `provider` is federated to that external
|
||||
// IdP (Google/GitHub, …) instead of the hosted credential login. The
|
||||
// client + redirect_uri + PKCE policy above are already enforced, so the
|
||||
// federation broker starts from a validated request and a trusted target.
|
||||
//
|
||||
// It is decided BEFORE the session is consulted, because naming a provider
|
||||
// is an explicit instruction about WHICH identity to authenticate — the
|
||||
// person pressed "continue with Google" — and an ambient session is not an
|
||||
// answer to that. Which also means it can never be silent: the external IdP
|
||||
// is the one who decides, and reaching it is an interaction.
|
||||
if q.provider != "" {
|
||||
if p.none {
|
||||
return authorizeErrorRedirect(c, q, errInteractionRequired, "an external identity provider cannot be used without interaction")
|
||||
}
|
||||
return beginFederation(c, db, app, q, method)
|
||||
}
|
||||
|
||||
// Delegate to the hosted login with a clean, re-encoded request. The login
|
||||
// page posts credentials to /v1/iam/login, which mints the code.
|
||||
// SINGLE SIGN-ON. A live session answers the request outright — this is
|
||||
// the branch that means "log in once at the issuer and every other app
|
||||
// already knows you". It is skipped only when the client asked for a
|
||||
// screen (prompt=login / select_account), and its refusals are the OIDC
|
||||
// error codes prompt=none is owed.
|
||||
if !p.interactive() {
|
||||
code, refusal := silentGrant(c, db, app, q)
|
||||
if refusal == "" {
|
||||
return authorizeCodeRedirect(c, q, code)
|
||||
}
|
||||
if p.none {
|
||||
return authorizeErrorRedirect(c, q, refusal, "no interaction was permitted and the request could not be answered from an existing session")
|
||||
}
|
||||
}
|
||||
|
||||
// prompt=none has now been answered one way or the other; reaching here
|
||||
// with it set means the client asked for no UI and for a UI at once, which
|
||||
// `combined` already refused. Everything else gets the hosted login with a
|
||||
// clean, re-encoded request. The login page posts credentials to
|
||||
// /v1/iam/login, which mints the code.
|
||||
q.prompt = p.forwarded()
|
||||
return c.Redirect(302, hostedLoginTarget(app)+"?"+authorizeForwardQuery(q, method))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +181,7 @@ func authorizeParams(c *zip.Ctx) authorizeRequest {
|
||||
resource: param(c, "resource"),
|
||||
responseMode: param(c, "response_mode"),
|
||||
provider: param(c, "provider"),
|
||||
prompt: param(c, "prompt"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,15 +211,47 @@ func authorizeForwardQuery(q authorizeRequest, method string) string {
|
||||
}
|
||||
setIfPresent(v, "resource", q.resource)
|
||||
setIfPresent(v, "response_mode", q.responseMode)
|
||||
// The surviving prompt is carried to the page, because the page is what has
|
||||
// to act on it: `select_account` is a request to show an account CHOOSER
|
||||
// rather than a bare credential form, and only the UI can do that. `none`
|
||||
// never reaches here — it is answered above, without a page, which is what it
|
||||
// asked for.
|
||||
setIfPresent(v, "prompt", q.prompt)
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
// authorizeCodeRedirect returns a successful silent authorization to the client:
|
||||
// the code and the state, on the registered redirect_uri.
|
||||
//
|
||||
// It is the SAME return path an interactive sign-in takes — the browser lands on
|
||||
// the client's callback with a code it exchanges at /token — so nothing
|
||||
// downstream can tell the two apart, and nothing downstream has to.
|
||||
func authorizeCodeRedirect(c *zip.Ctx, q authorizeRequest, code string) error {
|
||||
v := url.Values{}
|
||||
v.Set("code", code)
|
||||
return authorizeRedirect(c, q, v)
|
||||
}
|
||||
|
||||
// authorizeErrorRedirect bounces a protocol error back to the (already
|
||||
// validated) redirect_uri with error+state, in the requested response mode.
|
||||
func authorizeErrorRedirect(c *zip.Ctx, q authorizeRequest, code, desc string) error {
|
||||
v := url.Values{}
|
||||
v.Set("error", code)
|
||||
setIfPresent(v, "error_description", desc)
|
||||
return authorizeRedirect(c, q, v)
|
||||
}
|
||||
|
||||
// authorizeRedirect returns the browser to the redirect_uri carrying v, in the
|
||||
// requested response mode, with `state` echoed.
|
||||
//
|
||||
// Success and failure share it deliberately. They are the same act — hand these
|
||||
// parameters to the client's registered address — and splitting them is how a
|
||||
// server ends up echoing state on one and forgetting it on the other, or
|
||||
// honouring response_mode=fragment for an error and not for a code.
|
||||
//
|
||||
// It runs only AFTER redirect_uri has been matched against the application's
|
||||
// registered list, which is what makes appending to it safe.
|
||||
func authorizeRedirect(c *zip.Ctx, q authorizeRequest, v url.Values) error {
|
||||
setIfPresent(v, "state", q.state)
|
||||
|
||||
sep := "?"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -7,6 +8,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
const testRedirect = "https://app.example/callback"
|
||||
@@ -85,7 +88,7 @@ func TestAuthorize_DelegatesValidRequest(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
challenge := ComputeS256Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
challenge := pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {"pub"},
|
||||
@@ -115,6 +118,82 @@ func TestAuthorize_DelegatesValidRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sign-in must run AT its brand's pinned issuer: the hanzo_fed browser
|
||||
// binding and the session are host-only cookies, while the IdP callback and
|
||||
// `iss` live at the issuer. An authorize served on an alias host (iam.hanzo.ai
|
||||
// folding into hanzo.id) is therefore answered with the SAME request relocated
|
||||
// to the issuer — 307, query intact, before anything is minted or set. Measured
|
||||
// live before this hop: a begin on iam.hanzo.ai set the cookie there and
|
||||
// registered the Google callback at hanzo.id, so every social sign-in on the
|
||||
// alias failed closed at the callback with "the federation session could not
|
||||
// be verified".
|
||||
func TestAuthorize_AliasHostRelocatesToIssuer(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
|
||||
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {"pub"},
|
||||
"redirect_uri": {testRedirect},
|
||||
"state": {"s-alias"},
|
||||
"code_challenge": {pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")},
|
||||
"provider": {"provider-google"},
|
||||
}
|
||||
target := authorizeURL(q)
|
||||
|
||||
t.Run("alias relocates, method kept, nothing set", func(t *testing.T) {
|
||||
for _, method := range []string{"GET", "POST"} {
|
||||
req := formReqNoBody(method, target)
|
||||
req.Host = "iam.hanzo.ai"
|
||||
resp, _ := do(t, app, req)
|
||||
if resp.StatusCode != 307 {
|
||||
t.Fatalf("%s status = %d, want 307", method, resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "https://hanzo.id"+target {
|
||||
t.Fatalf("%s Location = %q, want %q", method, loc, "https://hanzo.id"+target)
|
||||
}
|
||||
// Relocation precedes every mint: a cookie set here would be the
|
||||
// stranded-cookie bug this hop exists to close.
|
||||
if sc := resp.Header.Get("Set-Cookie"); sc != "" {
|
||||
t.Fatalf("%s relocation must set nothing; Set-Cookie = %q", method, sc)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issuer host is terminal", func(t *testing.T) {
|
||||
req := formReqNoBody("GET", target)
|
||||
req.Host = "hanzo.id"
|
||||
resp, _ := do(t, app, req)
|
||||
if resp.StatusCode == 307 {
|
||||
t.Fatalf("issuer host must not relocate; got 307 to %q", resp.Header.Get("Location"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown host folds to the default issuer", func(t *testing.T) {
|
||||
req := formReqNoBody("GET", target)
|
||||
req.Host = "www.zoolabs.id" // deliberately absent from testIssuerMap
|
||||
resp, _ := do(t, app, req)
|
||||
if resp.StatusCode != 307 {
|
||||
t.Fatalf("status = %d, want 307", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "https://hanzo.id"+target {
|
||||
t.Fatalf("Location = %q, want fold to the default issuer", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-idempotent map must not steer", func(t *testing.T) {
|
||||
installIssuerResolver(t, "https://a.example",
|
||||
`{"x.example":"https://a.example","a.example":"https://b.example"}`)
|
||||
req := formReqNoBody("GET", target)
|
||||
req.Host = "x.example"
|
||||
resp, _ := do(t, app, req)
|
||||
if resp.StatusCode == 307 {
|
||||
t.Fatalf("ping-pong map must serve in place; got 307 to %q", resp.Header.Get("Location"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A confidential client may authorize without PKCE (it authenticates with its
|
||||
// secret at the token endpoint).
|
||||
func TestAuthorize_ConfidentialWithoutPKCEDelegates(t *testing.T) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package oidc
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -7,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -20,7 +22,7 @@ func testApp() *schema.Application {
|
||||
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-abc-000000000000000000000000000000000"
|
||||
ch := ComputeS256Challenge(verifier)
|
||||
ch := pkce.Challenge(verifier)
|
||||
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -49,7 +51,7 @@ func TestMintCode_RefusesPlain(t *testing.T) {
|
||||
func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-happy-0000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now.Add(30*time.Second)); err != nil {
|
||||
t.Fatalf("valid redemption rejected: %v", err)
|
||||
}
|
||||
@@ -58,7 +60,7 @@ func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-replay-000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
// First redemption + issue marks it used.
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -75,7 +77,7 @@ func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-exp-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
past := now.Add(codeTTL + time.Second)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, past); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("expired code: got %v, want ErrCodeExpired", err)
|
||||
@@ -85,7 +87,7 @@ func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-cli-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "some-other-app", verifier, now); !errors.Is(err, ErrClientMismatch) {
|
||||
t.Fatalf("client mismatch: got %v, want ErrClientMismatch", err)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
|
||||
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "the-WRONG-verifier-0000000000000000000000000", now); !errors.Is(err, ErrPKCEMismatch) {
|
||||
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
|
||||
}
|
||||
@@ -102,7 +104,7 @@ func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
func TestRedeemCode_PublicClientMustPresentVerifier(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
// Code minted WITH a challenge (public client) but token request omits the verifier.
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "", now); !errors.Is(err, ErrPKCEMissing) {
|
||||
t.Fatalf("missing verifier: got %v, want ErrPKCEMissing", err)
|
||||
}
|
||||
|
||||
+138
-55
@@ -1,9 +1,13 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -14,48 +18,48 @@ import (
|
||||
)
|
||||
|
||||
// GET/PUT /v1/iam/consent — the account-canonical data-sharing consent: the ONE
|
||||
// place a user's choice lives. The hanzo.id signup asks it, the browser extension
|
||||
// reads/writes it, and hanzo.ai edits it — all through here. It rides the SAME
|
||||
// self-scoped preferences blob as update-preferences (preferencesKey → "consent"),
|
||||
// so there is one store, one merge, one source of truth (no parallel table to drift).
|
||||
// place a user's choice is recorded. The hanzo.id signup asks it, the browser
|
||||
// extension reads/writes it, and hanzo.ai edits it — all through here. It rides
|
||||
// the SAME preferences blob as update-preferences, so there is one store and one
|
||||
// merge (no parallel table to drift).
|
||||
//
|
||||
// SELF-SCOPED: the target is ALWAYS the caller (callerOf), never a body field.
|
||||
// The value type, the tri-state, and the predicate live in schema.Consent — this
|
||||
// file is only the HTTP surface over them. Nothing here decides what an answer
|
||||
// MEANS; it records what the user said and reads it back.
|
||||
//
|
||||
// Two switches, privacy-first defaults when unset:
|
||||
// SELF-SCOPED: the target is ALWAYS the caller (callerOf), never a body field. A
|
||||
// caller can only ever write its own consent — not an org admin's view of a
|
||||
// member's, not a platform operator's. That is deliberate: consent someone else
|
||||
// can set on your behalf is not consent, and a write path that accepts a subject
|
||||
// from the body is the privilege-escalation shape this endpoint refuses to have.
|
||||
//
|
||||
// insights default TRUE — anonymous product usage (no query/answer text).
|
||||
// shareTraining default FALSE — OPT-IN to contribute the user's own data to
|
||||
// train Hanzo's open models.
|
||||
// AUDITED: a change to the record writes an AuditLog row carrying the whole
|
||||
// consent before and after, ON THE SAME TRANSACTION, so a grant AND a later
|
||||
// revocation are both attributable and neither can commit without its evidence.
|
||||
// Overwriting a field in a JSON blob leaves no history; the audit row is what
|
||||
// makes "who answered what, and when" answerable. The row is platform-written
|
||||
// (schema.PlatformWritten), so the generic audit CRUD cannot forge or remove one.
|
||||
const PathConsent = "/v1/iam/consent"
|
||||
|
||||
// consentKey nests the consent object inside the preferences blob.
|
||||
const consentKey = "consent"
|
||||
|
||||
type consentView struct {
|
||||
Insights bool `json:"insights"`
|
||||
ShareTraining bool `json:"shareTraining"`
|
||||
}
|
||||
|
||||
// consentOf reads consent out of a preferences JSON blob, applying the defaults
|
||||
// for a first-ever read (insights on, training off).
|
||||
func consentOf(prefs string) consentView {
|
||||
v := consentView{Insights: true, ShareTraining: false}
|
||||
if prefs == "" {
|
||||
return v
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(prefs), &m) != nil {
|
||||
return v
|
||||
}
|
||||
if raw, ok := m[consentKey]; ok {
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
}
|
||||
return v
|
||||
// consentBody is the wire shape, and every field is a POINTER so that "absent"
|
||||
// and "set to the zero value" are different requests. A consent screen that saves
|
||||
// only the switch it changed must not answer the other question by omission:
|
||||
// with a plain bool, a body of {"training":"granted"} also says insights=false,
|
||||
// silently revoking a choice the person never touched. Absent means UNTOUCHED.
|
||||
//
|
||||
// Training is a string rather than an Answer so an unrecognized token can be
|
||||
// REFUSED with a clear message instead of coerced — a client that invents a
|
||||
// spelling learns it was rejected, rather than having its user silently recorded
|
||||
// as unanswered.
|
||||
type consentBody struct {
|
||||
Insights *bool `json:"insights"`
|
||||
Training *string `json:"training"`
|
||||
}
|
||||
|
||||
// getConsentHandler returns the calling person's own privacy and communication
|
||||
// choices. Somebody who has never set them gets the defaults rather than
|
||||
// nothing, so a consent screen always has something to show.
|
||||
// nothing, so a consent screen always has something to show — insights on, and
|
||||
// training UNANSWERED, which is the state that means the screen still has to ask.
|
||||
func getConsentHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
@@ -67,15 +71,19 @@ func getConsentHandler(db orm.DB) zip.Handler {
|
||||
if err != nil || user == nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
return httpx.Ok(c, consentOf(user.Properties[preferencesKey]))
|
||||
return httpx.Ok(c, user.Consent())
|
||||
}
|
||||
}
|
||||
|
||||
// putConsentHandler records the calling person's privacy and communication
|
||||
// choices. Only their own — there is no way to set consent for somebody else.
|
||||
//
|
||||
// It merges rather than replaces, so saving a consent screen never discards a
|
||||
// preference some other screen set at the same moment.
|
||||
// Send only the answers you are changing. A question you leave out keeps the
|
||||
// answer it already had, so a screen that saves one switch never revokes the
|
||||
// other, and two screens saving at once do not undo each other.
|
||||
//
|
||||
// An answer this version does not recognize is refused here rather than stored,
|
||||
// so nothing is ever persisted for a later reader to have to interpret.
|
||||
func putConsentHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
@@ -83,33 +91,108 @@ func putConsentHandler(db orm.DB) zip.Handler {
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
var in consentView
|
||||
var in consentBody
|
||||
if err := json.Unmarshal(c.Fiber().Body(), &in); err != nil {
|
||||
return httpx.Err(c, "consent must be a JSON object")
|
||||
}
|
||||
if _, err := updateUser(ctx, db, owner, name, func(u *schema.User) error {
|
||||
merged := map[string]json.RawMessage{}
|
||||
if blob := u.Properties[preferencesKey]; blob != "" {
|
||||
_ = json.Unmarshal([]byte(blob), &merged)
|
||||
// Validate at the boundary: an answer this version does not know is
|
||||
// refused HERE rather than persisted for a later reader to interpret.
|
||||
// A field that is ABSENT is not an answer at all and is left alone; only
|
||||
// one that is present is checked, so silence can never fail validation
|
||||
// and can never change the record.
|
||||
var answer schema.Answer
|
||||
if in.Training != nil {
|
||||
answer = schema.Answer(*in.Training)
|
||||
if !answer.Valid() {
|
||||
return httpx.Err(c, "training must be one of: \"\", granted, refused")
|
||||
}
|
||||
cj, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
// Merge FIELD-WISE onto the stored record, under the row lock, so the
|
||||
// answers this request does not carry keep their committed values rather
|
||||
// than the zero values a decoder invented for them.
|
||||
var prior, next schema.Consent
|
||||
if _, err := updateUser(ctx, db, owner, name, func(tx orm.DB, u *schema.User) error {
|
||||
prior = u.Consent()
|
||||
next = prior
|
||||
if in.Insights != nil {
|
||||
next.Insights = *in.Insights
|
||||
}
|
||||
if in.Training != nil {
|
||||
next.Training = answer
|
||||
}
|
||||
if err := u.SetConsent(&next); err != nil {
|
||||
return err
|
||||
}
|
||||
merged[consentKey] = cj
|
||||
out, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Properties == nil {
|
||||
u.Properties = map[string]string{}
|
||||
}
|
||||
u.Properties[preferencesKey] = string(out)
|
||||
u.UpdatedTime = provisionNow()
|
||||
return nil
|
||||
// The evidence commits WITH the answer. Article 7(1) asks the
|
||||
// controller to demonstrate that the person consented, and a grant
|
||||
// whose audit row was written separately can be missing exactly when
|
||||
// it is needed — a failed second write, a crash between the two, a
|
||||
// row deleted later. Written on the same transaction, the record and
|
||||
// its evidence are one event: both, or neither.
|
||||
return auditConsent(ctx, tx, c, owner, name, prior, next)
|
||||
}); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, in)
|
||||
return httpx.Ok(c, next)
|
||||
}
|
||||
}
|
||||
|
||||
// consentChange is the audited payload — the WHOLE record before and after, not
|
||||
// just the training answer. Insights is a consent too: a withdrawal of it has to
|
||||
// be as demonstrable as a grant of the other, and an audit trail that records one
|
||||
// switch cannot answer "what did they consent to, and when" about the other.
|
||||
type consentChange struct {
|
||||
From schema.Consent `json:"from"`
|
||||
To schema.Consent `json:"to"`
|
||||
}
|
||||
|
||||
// auditConsent records a change to the consent record on the SAME transaction as
|
||||
// the record itself, so the answer and the evidence for it commit together.
|
||||
//
|
||||
// It returns its error, and that error aborts the write. A consent this system
|
||||
// cannot evidence is one it should not claim to hold: GDPR Article 7(1) puts the
|
||||
// burden of demonstrating consent on the controller, so a grant we cannot show
|
||||
// was given is worth less than no grant at all. Failing the request tells the
|
||||
// person their answer did not land, which is true and recoverable; recording it
|
||||
// silently unevidenced is neither.
|
||||
//
|
||||
// A request that changes NOTHING writes no row — re-saving an unchanged screen is
|
||||
// not an event, and a trail padded with them is harder to read.
|
||||
func auditConsent(ctx context.Context, tx orm.DB, c *zip.Ctx, owner, name string, from, to schema.Consent) error {
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
id, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit consent: %w", err)
|
||||
}
|
||||
object, err := json.Marshal(consentChange{From: from, To: to})
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit consent: %w", err)
|
||||
}
|
||||
log := orm.New[schema.AuditLog](tx)
|
||||
log.Owner = owner
|
||||
log.Name = id
|
||||
log.CreatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
log.Organization = owner
|
||||
log.User = owner + "/" + name
|
||||
log.Action = schema.ActionConsentTraining
|
||||
log.Object = string(object)
|
||||
log.Method = "PUT"
|
||||
log.RequestUri = c.Path()
|
||||
// ClientIp is deliberately EMPTY. Behind hanzoai/ingress the peer address is
|
||||
// the ingress pod, so the field recorded a value that identified nothing while
|
||||
// still being personal data we would owe a retention answer for. A field that
|
||||
// cannot support the conclusion it invites is worse than an absent one; the
|
||||
// authenticated subject is the attribution that matters here, and that is
|
||||
// already in User.
|
||||
log.StatusCode = 200
|
||||
log.IsTriggered = true
|
||||
log.SetId(owner + "/" + id)
|
||||
if err := log.CreateCtx(ctx); err != nil {
|
||||
return fmt.Errorf("audit consent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Consent has ONE writer. These tests are the two ways that could stop being
|
||||
// true: another endpoint reaching the same record, and this endpoint answering a
|
||||
// question the request never asked.
|
||||
|
||||
// The preferences surface shallow-merges whatever top-level keys a client sends,
|
||||
// unvalidated and unaudited. The consent record lives in that same blob — so
|
||||
// without this refusal, `POST /v1/iam/preferences {"consent":{...}}` is a second
|
||||
// writer of the one record that most needs a single one, and it bypasses the
|
||||
// answer validation and the audit row that make the real one accountable.
|
||||
func TestPreferencesRefusesTheConsentKey(t *testing.T) {
|
||||
for _, patch := range []string{
|
||||
`{"consent":{"training":"granted"}}`,
|
||||
`{"theme":"dark","consent":{"training":"granted"}}`,
|
||||
`{"consent":null}`,
|
||||
`{"consent":"granted"}`,
|
||||
} {
|
||||
t.Run(patch, func(t *testing.T) {
|
||||
_, _, err := mergePreferences(`{"consent":{"insights":true,"training":"refused"}}`, []byte(patch))
|
||||
if err == nil {
|
||||
t.Fatalf("the preferences surface accepted a consent patch: %s", patch)
|
||||
}
|
||||
if !strings.Contains(err.Error(), PathConsent) {
|
||||
t.Fatalf("the refusal must say where to answer instead, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// And it still merges everything that IS a preference.
|
||||
merged, m, err := mergePreferences(`{"consent":{"training":"granted"},"theme":"light"}`, []byte(`{"theme":"dark"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary preference patch was refused: %v", err)
|
||||
}
|
||||
if got := string(m["theme"]); got != `"dark"` {
|
||||
t.Fatalf("theme = %s, want \"dark\"", got)
|
||||
}
|
||||
// The stored consent is untouched by a write it is not part of.
|
||||
if !schema.ConsentOf(merged).MayTrain() {
|
||||
t.Fatalf("a preferences write altered the stored consent: %s", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// putConsent takes a raw JSON body so a test can express the difference between
|
||||
// "absent" and "present and false" — which is the whole property under test.
|
||||
func putConsent(t *testing.T, app *zip.App, cookie, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("PUT", PathConsent, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, raw := do(t, app, req)
|
||||
return resp.StatusCode, decode(t, raw)
|
||||
}
|
||||
|
||||
func consentOnRow(t *testing.T, db orm.DB) schema.Consent {
|
||||
t.Helper()
|
||||
u, err := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("read back alice: %v", err)
|
||||
}
|
||||
return u.Consent()
|
||||
}
|
||||
|
||||
// A consent screen saves the switch the person just moved. If an absent field
|
||||
// meant "false", saving one switch would silently revoke the other — the person
|
||||
// would answer one question and have a second answer changed on their behalf,
|
||||
// which is exactly what consent may not be.
|
||||
func TestConsentPutLeavesAnUnaskedQuestionAlone(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
// Establish a full record: insights on, training granted.
|
||||
if status, env := putConsent(t, app, cookie, `{"insights":true,"training":"granted"}`); status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("initial save: status=%d env=%v", status, env)
|
||||
}
|
||||
if got := consentOnRow(t, db); !got.MayTrain() || !got.Insights {
|
||||
t.Fatalf("initial save did not land: %+v", got)
|
||||
}
|
||||
|
||||
t.Run("training-only save keeps insights", func(t *testing.T) {
|
||||
if status, _ := putConsent(t, app, cookie, `{"training":"refused"}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
got := consentOnRow(t, db)
|
||||
if got.Training != schema.Refused {
|
||||
t.Fatalf("Training = %q, want refused", got.Training)
|
||||
}
|
||||
if !got.Insights {
|
||||
t.Fatal("a training-only save revoked the insights consent the person never touched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("insights-only save keeps training", func(t *testing.T) {
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
got := consentOnRow(t, db)
|
||||
if got.Insights {
|
||||
t.Fatal("insights=false did not land")
|
||||
}
|
||||
if got.Training != schema.Refused {
|
||||
t.Fatalf("an insights-only save changed the training answer to %q", got.Training)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit false is still an answer", func(t *testing.T) {
|
||||
// The tri-state must not turn into "absent and false are the same": a
|
||||
// person who deliberately switches insights off must be recorded off.
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":true}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if !consentOnRow(t, db).Insights {
|
||||
t.Fatal("insights=true did not land")
|
||||
}
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if consentOnRow(t, db).Insights {
|
||||
t.Fatal("an explicit insights=false was read as absent and ignored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty body changes nothing", func(t *testing.T) {
|
||||
before := consentOnRow(t, db)
|
||||
if status, _ := putConsent(t, app, cookie, `{}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if after := consentOnRow(t, db); after != before {
|
||||
t.Fatalf("an empty body rewrote the record: %+v -> %+v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unknown answer is refused and stores nothing", func(t *testing.T) {
|
||||
before := consentOnRow(t, db)
|
||||
status, env := putConsent(t, app, cookie, `{"training":"yes"}`)
|
||||
if status == 200 && env["status"] == "ok" {
|
||||
t.Fatal("training=\"yes\" was accepted")
|
||||
}
|
||||
if after := consentOnRow(t, db); after != before {
|
||||
t.Fatalf("a refused request still wrote: %+v -> %+v", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The audit row is the evidence that the answer was given, so it must carry the
|
||||
// WHOLE record — an insights withdrawal is as much a consent event as a training
|
||||
// grant — and it must be attributable without recording an address that only
|
||||
// identifies our own ingress.
|
||||
func TestConsentChangeIsAudited(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
rows := func() []*schema.AuditLog {
|
||||
t.Helper()
|
||||
got, err := orm.TypedQuery[schema.AuditLog](db).Filter("owner", "hanzo").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("read audit rows: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":true,"training":"granted"}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
after := rows()
|
||||
if len(after) != 1 {
|
||||
t.Fatalf("a consent grant wrote %d audit rows, want 1", len(after))
|
||||
}
|
||||
row := after[0]
|
||||
if row.Action != schema.ActionConsentTraining {
|
||||
t.Fatalf("Action = %q", row.Action)
|
||||
}
|
||||
if !schema.PlatformWritten(row.Action) {
|
||||
t.Fatal("the consent action is not reserved, so the row can be forged or deleted through the audit CRUD")
|
||||
}
|
||||
if row.User != "hanzo/alice" {
|
||||
t.Fatalf("User = %q, want the answering subject", row.User)
|
||||
}
|
||||
if row.ClientIp != "" {
|
||||
t.Fatalf("ClientIp = %q — behind the ingress this identifies nothing and is personal data we then owe an answer for", row.ClientIp)
|
||||
}
|
||||
var change consentChange
|
||||
if err := json.Unmarshal([]byte(row.Object), &change); err != nil {
|
||||
t.Fatalf("audited object is not a consent change: %q", row.Object)
|
||||
}
|
||||
if change.To.Training != schema.Granted || change.From.Training != schema.Unanswered {
|
||||
t.Fatalf("the transition was not recorded: %+v", change)
|
||||
}
|
||||
|
||||
// An insights-only change is a consent event too.
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if got := rows(); len(got) != 2 {
|
||||
t.Fatalf("an insights withdrawal wrote %d rows in total, want 2 — only the training answer is being audited", len(got))
|
||||
}
|
||||
|
||||
// Re-saving an unchanged screen is not an event.
|
||||
if status, _ := putConsent(t, app, cookie, `{"insights":false}`); status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if got := rows(); len(got) != 2 {
|
||||
t.Fatalf("a no-op save wrote an audit row (%d rows)", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// fakeSender records what it was asked to deliver and fails on demand.
|
||||
type fakeSender struct {
|
||||
err error
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, channel, dest, code string) error {
|
||||
f.sent = append(f.sent, channel+":"+dest+":"+code)
|
||||
return f.err
|
||||
}
|
||||
|
||||
// bindSender installs s for the duration of one test and restores the previous
|
||||
// binding after, so these tests can run in any order.
|
||||
func bindSender(t *testing.T, s Sender) {
|
||||
t.Helper()
|
||||
prev := sender
|
||||
sender = s
|
||||
t.Cleanup(func() { sender = prev })
|
||||
}
|
||||
|
||||
// A code sign-in is offered only when a code can actually reach a person.
|
||||
//
|
||||
// Two independent facts have to hold and they were conflated into one: the
|
||||
// application switch says the ORG wants email/SMS codes, and DeliveryConfigured
|
||||
// says the SERVER can send one. Only the first was consulted, so every app
|
||||
// advertised `code: true` while the delivery seam was unbound — measured against
|
||||
// production, where a send to probe@example.invalid, an address that cannot exist,
|
||||
// answered {status:"ok"}.
|
||||
func TestCodeSigninNeedsBothTheSwitchAndDelivery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
bound bool
|
||||
want bool
|
||||
}{
|
||||
{"wanted and deliverable", true, true, true},
|
||||
{"wanted but nothing can send it", true, false, false},
|
||||
{"deliverable but the org said no", false, true, false},
|
||||
{"neither", false, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.bound {
|
||||
bindSender(t, &fakeSender{})
|
||||
} else {
|
||||
bindSender(t, nil)
|
||||
}
|
||||
if got := tc.enabled && DeliveryConfigured(); got != tc.want {
|
||||
t.Errorf("code offered = %v, want %v (switch=%v bound=%v)",
|
||||
got, tc.want, tc.enabled, tc.bound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeliveryConfigured must answer from the BOUND SENDER, never from configuration.
|
||||
//
|
||||
// The first version of this gate keyed on IAM_NOTIFY_ADDR. Nothing else in the
|
||||
// repo read that variable, so setting it would have restored the button and
|
||||
// silenced the endpoint's refusal while still sending nothing — re-arming the
|
||||
// exact {status:"ok"} lie the gate exists to remove. An address is a CLAIM that
|
||||
// delivery exists; a sender IS delivery.
|
||||
func TestDeliveryIsDecidedByTheSenderNotAnAddress(t *testing.T) {
|
||||
bindSender(t, nil)
|
||||
t.Setenv("IAM_NOTIFY_ADDR", "notify.hanzo.svc:8000")
|
||||
if DeliveryConfigured() {
|
||||
t.Error("an address alone reported delivery configured — nothing would have been sent")
|
||||
}
|
||||
|
||||
bindSender(t, &fakeSender{})
|
||||
t.Setenv("IAM_NOTIFY_ADDR", "")
|
||||
if !DeliveryConfigured() {
|
||||
t.Error("a bound sender must report delivery configured, address or not")
|
||||
}
|
||||
}
|
||||
|
||||
// The login descriptor is the screen's source of truth, so the switch must be
|
||||
// masked THERE too — leaving it on would draw the button whatever authMethods says.
|
||||
// The org's stored setting is not modified; only what the browser is told.
|
||||
func TestLoginViewMasksUndeliverableCodeSignin(t *testing.T) {
|
||||
app := &schema.Application{EnableCodeSignin: true, EnablePassword: true}
|
||||
|
||||
bindSender(t, nil)
|
||||
if v := loginView(app); v.EnableCodeSignin {
|
||||
t.Error("code sign-in advertised with no delivery configured")
|
||||
}
|
||||
if !app.EnableCodeSignin {
|
||||
t.Error("the org's stored setting was mutated; only the VIEW may be masked")
|
||||
}
|
||||
if v := loginView(app); !v.EnablePassword {
|
||||
t.Error("password sign-in must be unaffected")
|
||||
}
|
||||
|
||||
bindSender(t, &fakeSender{})
|
||||
if v := loginView(app); !v.EnableCodeSignin {
|
||||
t.Error("code sign-in must return once a sender is bound — no second switch to flip")
|
||||
}
|
||||
}
|
||||
|
||||
// A sender that fails must be reported as a failure. Answering ok because the code
|
||||
// was minted recreates the same lie one layer down: the caller asked for a send.
|
||||
func TestSendFailureIsReportedNotSwallowed(t *testing.T) {
|
||||
f := &fakeSender{err: errors.New("twilio: 21608 unverified number")}
|
||||
bindSender(t, f)
|
||||
|
||||
if err := sender.Send(context.Background(), "email", "someone@example.com", "123456"); err == nil {
|
||||
t.Fatal("a failing sender must surface its error to the endpoint")
|
||||
}
|
||||
if len(f.sent) != 1 {
|
||||
t.Fatalf("sender was called %d times, want 1", len(f.sent))
|
||||
}
|
||||
if f.sent[0] != "email:someone@example.com:123456" {
|
||||
t.Errorf("sender got %q — channel, destination and code must all reach it", f.sent[0])
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -94,12 +95,18 @@ type deviceResponse struct {
|
||||
// public device client by its client_id — so it needs no bearer and joins no
|
||||
// allow-list, membership in this group is what makes it reachable.
|
||||
//
|
||||
// The sibling GET names the client a pending user_code belongs to. It is on the
|
||||
// The sibling POST names the client a pending user_code belongs to. It is on the
|
||||
// same public group and authenticates the same way every browser path here does:
|
||||
// by the session cookie, resolved inline.
|
||||
//
|
||||
// POST for a read, deliberately, and for the reason RFC 7662 introspection beside
|
||||
// it is POST: the argument is a SECRET. A user_code in a request line is copied
|
||||
// into ingress and proxy access logs, which a POST body is not — and this flow's
|
||||
// own approval page ships a scrubUrl() to keep the code out of the address bar,
|
||||
// so putting it back into every request line would undo that on the server side.
|
||||
func routeDevice(r zip.Router, db orm.DB) {
|
||||
r.Post(PathDevice, deviceHandler(db))
|
||||
r.Get(PathDevice+"/:userCode", deviceInfoHandler(db))
|
||||
r.Post(PathDeviceInfo, deviceInfoHandler(db))
|
||||
}
|
||||
|
||||
// deviceInfo is what the approval page must show a human: WHICH application is
|
||||
@@ -143,8 +150,20 @@ func deviceInfoHandler(db orm.DB) zip.Handler {
|
||||
return httpx.ErrCode(c, "please sign in first", CodeLoginRequired)
|
||||
}
|
||||
|
||||
// JSON body from the approval page, form/query for anything else — the same
|
||||
// bind-then-fall-back the login front door uses, so one endpoint serves both
|
||||
// without a second spelling of the request.
|
||||
var f struct {
|
||||
UserCode string `json:"userCode"`
|
||||
}
|
||||
_ = c.Bind(&f)
|
||||
userCode := f.UserCode
|
||||
if userCode == "" {
|
||||
userCode = param(c, "userCode")
|
||||
}
|
||||
|
||||
const refuse = "the user code is invalid or expired"
|
||||
row, err := store.GetTokenByUserCode(ctx, db, c.Param("userCode"))
|
||||
row, err := store.GetTokenByUserCode(ctx, db, userCode)
|
||||
if err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -18,10 +19,11 @@ import (
|
||||
//
|
||||
// These tests pin the property that fixes it: the name comes off the CODE.
|
||||
|
||||
// deviceInfoGet drives GET /v1/iam/oauth/device/:userCode with an optional session.
|
||||
// deviceInfoGet drives POST /v1/iam/oauth/device/info with an optional session.
|
||||
// The code rides the BODY, never a request line — it is the one secret here.
|
||||
func deviceInfoGet(t *testing.T, app *zip.App, userCode, cookie string) map[string]any {
|
||||
t.Helper()
|
||||
req := formReqNoBody("GET", PathDevice+"/"+url.PathEscape(userCode))
|
||||
req := jsonReq("POST", PathDeviceInfo, map[string]string{"userCode": userCode})
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -474,7 +476,7 @@ func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("setup: no authorization code minted")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -381,7 +382,7 @@ func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, pr
|
||||
if u, err := store.GetUserByEmail(ctx, db, org, id.email); err != nil {
|
||||
return nil, err
|
||||
} else if u != nil {
|
||||
linked, err := updateUser(ctx, db, u.Owner, u.Name, func(fresh *schema.User) error {
|
||||
linked, err := updateUser(ctx, db, u.Owner, u.Name, func(_ orm.DB, fresh *schema.User) error {
|
||||
*binding.ref(fresh) = id.subject
|
||||
fresh.EmailVerified = true
|
||||
return nil
|
||||
@@ -448,7 +449,7 @@ func federationProvider(app *schema.Application, name string) *schema.Provider {
|
||||
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
|
||||
continue
|
||||
}
|
||||
if !isConfigured(it.Provider) {
|
||||
if !offerable(it.Provider) {
|
||||
continue
|
||||
}
|
||||
return it.Provider
|
||||
@@ -479,7 +480,7 @@ func federationCallbackURL(c *zip.Ctx) string {
|
||||
// that brand's pinned origin, header-immune and never steered to an attacker
|
||||
// origin. See resolveIssuer for the fail-closed resolution order.
|
||||
func federationBaseURL(c *zip.Ctx) string {
|
||||
return resolveIssuer(c.Host())
|
||||
return resolveFederationOrigin(c.Host())
|
||||
}
|
||||
|
||||
// federationOrgAllowed reports whether a federated (external) identity may be
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import "testing"
|
||||
|
||||
// The federation callback is not an internal detail. It is the redirect_uri iam
|
||||
// hands every external IdP, and an IdP refuses any value it was not told about in
|
||||
// advance. So this string is a CONTRACT held in two places at once: here, and in
|
||||
// each provider's own console.
|
||||
//
|
||||
// Nothing in this package can observe the other half. When federation moved off
|
||||
// Casdoor's `<iam host>/callback` to the canonical path below, the GitHub App's
|
||||
// callback list was updated and Google's OAuth client was not — so Google refused
|
||||
// sign-in on EVERY brand with `Error 400: redirect_uri_mismatch` while this suite
|
||||
// stayed green, GitHub kept working, and the only report was a person who could
|
||||
// not log in.
|
||||
//
|
||||
// ⚠️ ASSERT THROUGH resolveFederationOrigin, NEVER resolveIssuer. The two were one
|
||||
// value until the origin was unbraided from the issuer; today, with no
|
||||
// IAM_FEDERATION_ORIGIN set, the federation resolver FALLS BACK to the issuer, so
|
||||
// both spellings pass and the wrong one is indistinguishable from the right one.
|
||||
// The moment an origin is pinned — which is the entire point of that split — a
|
||||
// test written against resolveIssuer keeps passing while the real callback moves.
|
||||
// That is the exact false green this file exists to prevent, so it is worth the
|
||||
// one line of care.
|
||||
func TestFederationCallbackIsTheRegisteredContract(t *testing.T) {
|
||||
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
|
||||
|
||||
for host, want := range map[string]string{
|
||||
"hanzo.id": "https://hanzo.id/v1/iam/oauth/callback",
|
||||
"iam.hanzo.ai": "https://hanzo.id/v1/iam/oauth/callback",
|
||||
"lux.id": "https://lux.id/v1/iam/oauth/callback",
|
||||
"iam.lux.network": "https://lux.id/v1/iam/oauth/callback",
|
||||
"id.zoo.network": "https://id.zoo.network/v1/iam/oauth/callback",
|
||||
"pars.id": "https://pars.id/v1/iam/oauth/callback",
|
||||
} {
|
||||
// The composition federationCallbackURL performs, through the same seam a
|
||||
// live request takes.
|
||||
if got := resolveFederationOrigin(host) + PathFederationCallback; got != want {
|
||||
t.Errorf("federation callback for %s = %s, want %s\n"+
|
||||
"If this change is intended, register the new URI with EVERY external IdP "+
|
||||
"(the Google OAuth client AND the GitHub App) BEFORE shipping — each refuses "+
|
||||
"any redirect_uri it does not already hold, and neither failure is visible from here.",
|
||||
host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What a pinned origin WOULD buy, and why it is not on offer yet.
|
||||
//
|
||||
// I wrote this test asserting that every host of one org folds onto ONE callback,
|
||||
// so a provider console holds one redirect_uri per org rather than one per brand
|
||||
// host. That property is desirable and it is NOT reachable: the begin leg sets the
|
||||
// `hanzo_fed` browser-binding cookie on the host that served it, host-only, and the
|
||||
// callback refuses an empty cookie — so a callback on a different host is never
|
||||
// given the cookie and every social sign-in on that brand fails closed. Asserting
|
||||
// it here made a broken configuration look supported.
|
||||
//
|
||||
// InitFederationResolver now refuses that config at boot
|
||||
// (TestFederationOriginCrossHostFoldIsRefusedAtBoot pins the refusal and its
|
||||
// wording). What remains true, and what this pins, is that a SAME-HOST map is a
|
||||
// no-op: each brand keeps its own callback, which is the list actually registered
|
||||
// with Google and GitHub today.
|
||||
func TestFederationCallbackPerBrandUnderASameHostMap(t *testing.T) {
|
||||
t.Setenv("IAM_ISSUER", "https://hanzo.id")
|
||||
t.Setenv("IAM_ISSUER_MAP", `{"hanzo.id":"https://hanzo.id","lux.id":"https://lux.id"}`)
|
||||
t.Setenv("IAM_FEDERATION_ORIGIN", "https://hanzo.id")
|
||||
t.Setenv("IAM_FEDERATION_ORIGIN_MAP", `{"hanzo.id":"https://hanzo.id","lux.id":"https://lux.id"}`)
|
||||
|
||||
prevIss, prevFed := activeResolver.Load(), activeFederationResolver.Load()
|
||||
t.Cleanup(func() { activeResolver.Store(prevIss); activeFederationResolver.Store(prevFed) })
|
||||
activeResolver.Store(nil)
|
||||
activeFederationResolver.Store(nil)
|
||||
if err := InitIssuerResolver(); err != nil {
|
||||
t.Fatalf("InitIssuerResolver: %v", err)
|
||||
}
|
||||
if err := InitFederationResolver(); err != nil {
|
||||
t.Fatalf("InitFederationResolver: %v", err)
|
||||
}
|
||||
|
||||
for host, want := range map[string]string{
|
||||
"hanzo.id": "https://hanzo.id/v1/iam/oauth/callback",
|
||||
"lux.id": "https://lux.id/v1/iam/oauth/callback",
|
||||
} {
|
||||
if got := resolveFederationOrigin(host) + PathFederationCallback; got != want {
|
||||
t.Errorf("callback for %s = %s, want %s — each brand keeps its own until the "+
|
||||
"begin leg can set the cookie on a folded origin", host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -277,7 +279,7 @@ func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationS
|
||||
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("nonce", st.IdpNonce)
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
return joinQuery(cfg.authURL, v)
|
||||
}
|
||||
@@ -377,7 +379,7 @@ func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback
|
||||
v.Set("state", st.Name)
|
||||
v.Set("allow_signup", "true")
|
||||
if p.EnablePkce {
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
}
|
||||
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -264,7 +266,7 @@ func beginAuthorize(t *testing.T, app *zip.App, clientID, provider string) (url.
|
||||
"redirect_uri": {testRedirect},
|
||||
"scope": {"openid email profile"},
|
||||
"state": {fedAppState},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"provider": {provider},
|
||||
}
|
||||
@@ -700,7 +702,7 @@ func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
|
||||
q := url.Values{
|
||||
"response_type": {"code"}, "client_id": {"webapp"},
|
||||
"redirect_uri": {"https://evil.example/steal"},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"provider": {fedProvGoogle},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
|
||||
@@ -775,7 +777,7 @@ func federationAuthorizeQuery(clientID string) url.Values {
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {testRedirect},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {fedAppState},
|
||||
"provider": {fedProvGoogle},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -89,7 +90,7 @@ func unlink(db orm.DB) zip.Handler {
|
||||
return httpx.Err(c, "this provider can't be unlinked")
|
||||
}
|
||||
|
||||
if _, err := updateUser(ctx, db, f.User.Owner, f.User.Name, func(fresh *schema.User) error {
|
||||
if _, err := updateUser(ctx, db, f.User.Owner, f.User.Name, func(_ orm.DB, fresh *schema.User) error {
|
||||
*b.ref(fresh) = ""
|
||||
return nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
@@ -19,7 +20,7 @@ func newUnlinkServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam-unlink-test", DisableStartupMessage: true})
|
||||
Route(app.Group(""), db) // public: authorize/login/token AND the self-authenticating unlink
|
||||
Route(app.Group("").(*zip.App), db) // public: authorize/login/token AND the self-authenticating unlink
|
||||
return app, db
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
|
||||
+146
-54
@@ -1,8 +1,10 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
@@ -25,9 +27,31 @@ const PathAuthMethods = "/v1/iam/auth/methods"
|
||||
// caller itself (callerOf: session cookie first, then bearer) and SELF-SCOPES to
|
||||
// that caller, so — like the rest of this group — they are reachable without a
|
||||
// Guard-verified bearer yet never act on anyone but the resolved caller.
|
||||
func routeFrontDoor(r zip.Router, db orm.DB) {
|
||||
zip.Alias(r.Get, PathAuthApplication, LegacyPathAuthApplication, getAppLogin(db))
|
||||
r.Get(PathAuthMethods, authMethods(db))
|
||||
func routeFrontDoor(r *zip.App, db orm.DB) {
|
||||
// The two login-screen descriptors are TYPED ops. Their whole input is a client
|
||||
// id off the query string and their answer is this envelope, so nothing about
|
||||
// them needed a raw handler — and a raw handler is what kept them out of the
|
||||
// schema, the MCP tool list, the CLI and every generated SDK. Registered on the
|
||||
// PUBLIC group, which carries no op-invoke authorizer, so typing them changes
|
||||
// what they PUBLISH and nothing about who may call them.
|
||||
//
|
||||
// The older spelling of the first is the SAME op at its legacy address, so one
|
||||
// function decides both answers and they cannot drift.
|
||||
//
|
||||
// It carries the same tag as its canonical twin rather than "compat", and that
|
||||
// is a deliberate limit on the blast radius of a TYPING change. The compat tag
|
||||
// is how an address is kept OUT of the published document, and this address is
|
||||
// in it today — as an untyped route, which has no way to say compat. Tagging it
|
||||
// now would delete a published path, and a published path that disappears is
|
||||
// what cloud's per-product floor exists to refuse. Retiring the spelling is a
|
||||
// surface decision that lowers that floor in the same commit; this is not that
|
||||
// commit.
|
||||
zip.Get[screen, httpx.Answer](r, PathAuthApplication, getAppLogin(db),
|
||||
zip.WithStatus(200, 400), zip.WithTags("auth"))
|
||||
zip.Get[screen, httpx.Answer](r, LegacyPathAuthApplication, getAppLogin(db),
|
||||
zip.WithStatus(200, 400), zip.WithTags("auth"))
|
||||
zip.Get[offer, httpx.Answer](r, PathAuthMethods, authMethods(db),
|
||||
zip.WithStatus(200, 400), zip.WithTags("auth"))
|
||||
// The account read is anonymous-safe (returns {status:"error"} unauthenticated)
|
||||
// and a security contract — the gateway admin-guard reads its `owner`.
|
||||
zip.Alias(r.Get, PathAccount, LegacyPathAccount, getAccount(db))
|
||||
@@ -61,24 +85,23 @@ func routeFrontDoor(r zip.Router, db orm.DB) {
|
||||
//
|
||||
// The client secret is masked. Read before anyone has signed in, so it carries
|
||||
// only what is safe for a browser to see.
|
||||
func getAppLogin(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if rt := c.Query("responseType"); rt != "" && rt != "code" {
|
||||
return httpx.Err(c, "response_type is required (must be code)")
|
||||
func getAppLogin(db orm.DB) zip.TypedHandler[screen, httpx.Answer] {
|
||||
return func(ctx context.Context, in *screen) (*httpx.Answer, error) {
|
||||
if in.ResponseType != "" && in.ResponseType != "code" {
|
||||
return httpx.Bad(400, "response_type is required (must be code)", ""), nil
|
||||
}
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
if in.ClientId == "" {
|
||||
return httpx.Bad(400, "clientId is required", ""), nil
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
app, err := store.GetApplicationByClientId(ctx, db, in.ClientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
return httpx.Bad(400, err.Error(), ""), nil
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
return httpx.Bad(400, "the application does not exist", ""), nil
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
return httpx.Ok(c, maskApp(app))
|
||||
store.EnrichProviders(ctx, db, app)
|
||||
return httpx.Good(loginView(app)), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,34 +111,29 @@ func getAppLogin(db orm.DB) zip.Handler {
|
||||
//
|
||||
// Public by design: it is read before anyone has signed in, and it exposes only
|
||||
// which methods exist, never their credentials.
|
||||
func authMethods(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
func authMethods(db orm.DB) zip.TypedHandler[offer, httpx.Answer] {
|
||||
return func(ctx context.Context, in *offer) (*httpx.Answer, error) {
|
||||
if in.ClientId == "" {
|
||||
return httpx.Bad(400, "clientId is required", ""), nil
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
app, err := store.GetApplicationByClientId(ctx, db, in.ClientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
return httpx.Bad(400, err.Error(), ""), nil
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
return httpx.Bad(400, "the application does not exist", ""), nil
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
store.EnrichProviders(ctx, db, app)
|
||||
|
||||
oauth := []map[string]string{}
|
||||
web3 := false
|
||||
for _, it := range app.Providers {
|
||||
if it == nil || it.Provider == nil || !it.CanSignIn {
|
||||
continue
|
||||
}
|
||||
if !isConfigured(it.Provider) {
|
||||
if !offerable(it.Provider) {
|
||||
continue // hidden until real creds land — never a dead-end button
|
||||
}
|
||||
switch strings.ToLower(it.Provider.Category) {
|
||||
case "web3":
|
||||
web3 = true
|
||||
case "oauth":
|
||||
if strings.EqualFold(it.Provider.Category, "oauth") {
|
||||
oauth = append(oauth, map[string]string{
|
||||
"name": it.Name,
|
||||
"type": it.Provider.Type,
|
||||
@@ -123,21 +141,76 @@ func authMethods(db orm.DB) zip.Handler {
|
||||
})
|
||||
}
|
||||
}
|
||||
return httpx.Ok(c, map[string]any{
|
||||
|
||||
// Wallet sign-in is a capability of THIS BINARY, not of an application's
|
||||
// provider list, so it is asked of the package that serves it. It used to
|
||||
// be read off a linked provider of category "web3" — the seeded
|
||||
// Web3Onboard row, whose clientId is the unexpanded literal
|
||||
// `${IAM_WEB3_CLIENT_ID}` and which names a third-party library this build
|
||||
// does not import. So every login screen reported web3:false while
|
||||
// /v1/iam/web3/nonce answered on seven chain families, and the flag tracked
|
||||
// a row that governed nothing.
|
||||
//
|
||||
// The chain list is the SAME one the nonce/verify endpoints gate on, so a
|
||||
// screen cannot offer a chain the endpoint refuses.
|
||||
names := schema.WalletChains()
|
||||
return httpx.Good(map[string]any{
|
||||
"password": app.EnablePassword,
|
||||
"code": app.EnableCodeSignin,
|
||||
// Offered only when a code can actually be delivered. The app switch says
|
||||
// the org WANTS email/SMS codes; DeliveryConfigured says the server can
|
||||
// send one. Both must hold, or the screen shows a method that ends in a
|
||||
// person waiting for a message nobody sent.
|
||||
"code": app.EnableCodeSignin && DeliveryConfigured(),
|
||||
"webauthn": app.EnableWebAuthn,
|
||||
"web3": web3,
|
||||
"oauth": oauth,
|
||||
"signup": app.EnableSignUp,
|
||||
})
|
||||
"web3": len(names) > 0,
|
||||
// The families a wallet may sign in with, so a screen can render the
|
||||
// right options instead of hardcoding a list that drifts from the
|
||||
// verifier. Additive: `web3` stays the boolean every client reads.
|
||||
"web3Chains": names,
|
||||
"oauth": oauth,
|
||||
"signup": app.EnableSignUp,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigured reports whether a provider holds a real (non-placeholder)
|
||||
// credential — the guard that keeps an unconfigured provider's button hidden so
|
||||
// it never dead-ends the OAuth redirect.
|
||||
func isConfigured(p *schema.Provider) bool {
|
||||
// screen is what a login screen names when it asks how to draw itself: the
|
||||
// application, and the OAuth response type it intends to use.
|
||||
type screen struct {
|
||||
// ClientId is the application's OAuth client id — the one field that selects
|
||||
// which login screen this is.
|
||||
ClientId string `json:"clientId"`
|
||||
// ResponseType is the OAuth response type the screen will ask for. Only "code"
|
||||
// is served; anything else is refused here rather than at the authorize leg,
|
||||
// where the person has already typed a password.
|
||||
ResponseType string `json:"responseType"`
|
||||
}
|
||||
|
||||
// offer is the application whose enabled sign-in methods are being asked for.
|
||||
type offer struct {
|
||||
// ClientId is the application's OAuth client id.
|
||||
ClientId string `json:"clientId"`
|
||||
}
|
||||
|
||||
// offerable reports whether a provider can actually COMPLETE a sign-in, which is
|
||||
// the only honest reason to draw a button for it. A method fails that in two
|
||||
// independent ways, and checking only the first is what put dead buttons on the
|
||||
// login screen:
|
||||
//
|
||||
// - NO REAL CREDENTIAL — a placeholder client id, never filled in.
|
||||
// - NO DIALECT THAT CAN DRIVE IT. idpKind is the ONE authority for "can we
|
||||
// federate this?", and it is what the authorize leg already consults. GitLab
|
||||
// is the live example: a real-looking client id passes the credential check,
|
||||
// so the button rendered, and then beginFederation refused it with "provider
|
||||
// is not a supported federation type". The button existed only to fail.
|
||||
//
|
||||
// Both callers now ask THIS question — the login screen and the authorize leg —
|
||||
// so what is offered and what is driveable can no longer disagree. Give GitLab an
|
||||
// issuerUrl and it becomes a real OIDC provider here and its button returns, with
|
||||
// nothing else to change.
|
||||
//
|
||||
// Web3 is exempt from the dialect check because it never reaches the federation
|
||||
// broker at all: it is native challenge/response with no OAuth client.
|
||||
func offerable(p *schema.Provider) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
@@ -145,6 +218,9 @@ func isConfigured(p *schema.Provider) bool {
|
||||
if strings.EqualFold(p.Category, "Web3") {
|
||||
return true
|
||||
}
|
||||
if idpKind(p) == "" {
|
||||
return false
|
||||
}
|
||||
id := strings.ToLower(strings.TrimSpace(p.ClientId))
|
||||
if id == "" {
|
||||
return false
|
||||
@@ -155,22 +231,38 @@ func isConfigured(p *schema.Provider) bool {
|
||||
!strings.Contains(id, "change")
|
||||
}
|
||||
|
||||
// maskApp returns a copy-safe view of the application with the client secret and
|
||||
// every provider's secret removed — get-app-login is called by the browser, so
|
||||
// no secret may cross it.
|
||||
func maskApp(app *schema.Application) *schema.Application {
|
||||
// loginView returns what a login screen may see of an application: no secrets,
|
||||
// and no sign-in method that cannot complete.
|
||||
//
|
||||
// Both halves are here because this response IS the login screen's source of
|
||||
// truth — the SDK calls it "the canonical source of truth for which methods
|
||||
// exist" — so a provider present here is a button rendered. It previously
|
||||
// answered with EVERY provider while /v1/iam/auth/methods answered with the
|
||||
// offerable ones: two endpoints, two answers to one question, and the browser
|
||||
// read the unfiltered one. Filtering here is what makes them agree.
|
||||
func loginView(app *schema.Application) *schema.Application {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
masked := *app
|
||||
masked.ClientSecret = ""
|
||||
for _, it := range masked.Providers {
|
||||
if it != nil && it.Provider != nil {
|
||||
p := *it.Provider
|
||||
p.ClientSecret = ""
|
||||
p.ClientSecret2 = ""
|
||||
it.Provider = &p
|
||||
view := *app
|
||||
view.ClientSecret = ""
|
||||
// Same rule as the provider list below: this response IS the login screen's
|
||||
// source of truth, so a switch left on for a method the server cannot perform
|
||||
// would draw the button anyway. The org's stored setting is untouched — only
|
||||
// what the browser is told.
|
||||
view.EnableCodeSignin = view.EnableCodeSignin && DeliveryConfigured()
|
||||
kept := make([]*schema.ProviderItem, 0, len(view.Providers))
|
||||
for _, it := range view.Providers {
|
||||
if it == nil || it.Provider == nil || !offerable(it.Provider) {
|
||||
continue
|
||||
}
|
||||
p := *it.Provider
|
||||
p.ClientSecret = ""
|
||||
p.ClientSecret2 = ""
|
||||
item := *it
|
||||
item.Provider = &p
|
||||
kept = append(kept, &item)
|
||||
}
|
||||
return &masked
|
||||
view.Providers = kept
|
||||
return &view
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package oidc
|
||||
|
||||
import (
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/sessions"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -62,7 +64,7 @@ func TestSignin_CodeExchangeSetsSessionAndReturnsAccount(t *testing.T) {
|
||||
}
|
||||
// It establishes the durable session get-account resolves from.
|
||||
cookie := resp.Header.Get("Set-Cookie")
|
||||
if !strings.HasPrefix(cookie, "hanzo_session=") {
|
||||
if !strings.HasPrefix(cookie, sessions.CookieName+"=") {
|
||||
t.Fatalf("signin did not set the session cookie: %q", cookie)
|
||||
}
|
||||
req := formReqNoBody("GET", PathAccount)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// A login screen may only offer what can finish. These cases are the live estate
|
||||
// at the time this was written: of FIVE providers on hanzo-app, exactly two could
|
||||
// ever complete a sign-in, and the other three rendered as buttons anyway.
|
||||
func TestOfferableOffersOnlyWhatCanComplete(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
p schema.Provider
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
// The two that work.
|
||||
name: "google",
|
||||
p: schema.Provider{Type: "Google", Category: "OAuth", ClientId: "113591532635-real.apps.googleusercontent.com"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
p: schema.Provider{Type: "GitHub", Category: "OAuth", ClientId: "Iv23li3SYLoq40ExR6EN"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// THE BUG. A real-looking client id passed the credential check, so the
|
||||
// button rendered — and the authorize leg then refused it with
|
||||
// "provider is not a supported federation type", because nothing can
|
||||
// drive a GitLab that declares no OIDC issuer.
|
||||
name: "gitlab without an issuer is not driveable",
|
||||
p: schema.Provider{Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// THE ESCAPE HATCH, and the reason this is a capability test rather
|
||||
// than a deny-list of type names: GitLab IS an OIDC provider. Declare
|
||||
// the issuer and it becomes driveable here with no code change — the
|
||||
// button comes back on its own.
|
||||
name: "gitlab with an issuer is driveable",
|
||||
p: schema.Provider{Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b",
|
||||
IssuerUrl: "https://gitlab.com"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Never configured — placeholder credentials, both of them.
|
||||
name: "apple placeholder",
|
||||
p: schema.Provider{Type: "Apple", Category: "OAuth", ClientId: "placeholder"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "web3onboard placeholder under the OAuth category",
|
||||
p: schema.Provider{Type: "Web3Onboard", Category: "OAuth", ClientId: "placeholder"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Web3 proper never reaches the federation broker, so requiring a
|
||||
// dialect of it would hide a method that genuinely works.
|
||||
name: "native web3 needs no dialect and no client",
|
||||
p: schema.Provider{Type: "Web3Onboard", Category: "Web3"},
|
||||
want: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := offerable(&tc.p); got != tc.want {
|
||||
t.Errorf("offerable(%s/%s issuer=%q) = %v, want %v",
|
||||
tc.p.Type, tc.p.Category, tc.p.IssuerUrl, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if offerable(nil) {
|
||||
t.Error("offerable(nil) = true; a missing provider is never offerable")
|
||||
}
|
||||
}
|
||||
|
||||
// The login screen's source of truth must not carry a method it cannot complete —
|
||||
// and must not carry a secret. get-app-login previously answered with EVERY
|
||||
// provider while auth/methods answered with the offerable ones; the browser read
|
||||
// the unfiltered one, which is why the dead buttons were visible at all.
|
||||
func TestLoginViewDropsUnofferableAndSecrets(t *testing.T) {
|
||||
app := &schema.Application{
|
||||
ClientSecret: "app-secret-must-not-cross",
|
||||
Providers: []*schema.ProviderItem{
|
||||
{Name: "provider-google", CanSignIn: true, Provider: &schema.Provider{
|
||||
Type: "Google", Category: "OAuth", ClientId: "real.apps.googleusercontent.com",
|
||||
ClientSecret: "GOCSPX-must-not-cross", ClientSecret2: "also-must-not-cross"}},
|
||||
{Name: "provider-gitlab", CanSignIn: true, Provider: &schema.Provider{
|
||||
Type: "GitLab", Category: "OAuth", ClientId: "5a68c0e6b690f4b3cc92f9a95a4ad52b"}},
|
||||
{Name: "provider-apple", CanSignIn: true, Provider: &schema.Provider{
|
||||
Type: "Apple", Category: "OAuth", ClientId: "placeholder"}},
|
||||
},
|
||||
}
|
||||
|
||||
view := loginView(app)
|
||||
|
||||
if len(view.Providers) != 1 || view.Providers[0].Name != "provider-google" {
|
||||
var got []string
|
||||
for _, it := range view.Providers {
|
||||
got = append(got, it.Name)
|
||||
}
|
||||
t.Fatalf("loginView kept %v, want only [provider-google]", got)
|
||||
}
|
||||
if view.ClientSecret != "" {
|
||||
t.Error("loginView leaked the application client secret")
|
||||
}
|
||||
if p := view.Providers[0].Provider; p.ClientSecret != "" || p.ClientSecret2 != "" {
|
||||
t.Error("loginView leaked a provider secret")
|
||||
}
|
||||
|
||||
// The source must be untouched: this is a VIEW, and the caller's application
|
||||
// is shared. Masking or filtering in place would strip the running config.
|
||||
if app.ClientSecret == "" || len(app.Providers) != 3 {
|
||||
t.Fatal("loginView mutated the application it was given")
|
||||
}
|
||||
if app.Providers[0].Provider.ClientSecret != "GOCSPX-must-not-cross" {
|
||||
t.Error("loginView mutated the source provider's secret")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package oidc
|
||||
|
||||
import (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user