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 | ||
|
|
eb7a760378 | ||
|
|
b466bd6302 | ||
|
|
e955314715 | ||
|
|
d9c1e89e8e | ||
|
|
e87e44bc97 | ||
|
|
0e12788c94 | ||
|
|
8fec86541e | ||
|
|
fa8afb4494 | ||
|
|
99e8bdfeb8 | ||
|
|
7af0326e2d | ||
|
|
e1629bd91e | ||
|
|
8a0ca1686f | ||
|
|
ab6b90fc8a | ||
|
|
9b4886c791 | ||
|
|
262f5f0c81 | ||
|
|
4160c2e12e | ||
|
|
1050448b08 | ||
|
|
cdd30428c9 | ||
|
|
29bda800a5 | ||
|
|
6b3c20eef1 | ||
|
|
b1584dfd98 | ||
|
|
b3c660f770 | ||
|
|
ba43c54e76 | ||
|
|
add9283033 | ||
|
|
d9381225bb | ||
|
|
10c84382f5 | ||
|
|
96c95792d0 | ||
|
|
92c26c3781 | ||
|
|
22b1184af8 | ||
|
|
3c63ee2889 | ||
|
|
c666bb1260 | ||
|
|
eb83277c7d | ||
|
|
7bf4b04dd7 | ||
|
|
84b90f80da | ||
|
|
8e33b4b1e7 | ||
|
|
d2aa268f94 | ||
|
|
daa08dba2a | ||
|
|
1ecb6954f8 | ||
|
|
27e7d299f8 | ||
|
|
faf2d2148f | ||
|
|
4890a4301b | ||
|
|
822e0c6af9 | ||
|
|
57ae7b3092 | ||
|
|
4347dbb144 | ||
|
|
fffa95d01e | ||
|
|
b227a026ae | ||
|
|
f32bc134a4 | ||
|
|
eb06f140bc | ||
|
|
0648788524 | ||
|
|
0ef5b609a9 | ||
|
|
31f36d236b | ||
|
|
0ea62feae5 | ||
|
|
6088330e42 | ||
|
|
2f7f8237ea | ||
|
|
5bdf104c38 | ||
|
|
fbe36d71ae | ||
|
|
0a31146909 | ||
|
|
542d4e9795 | ||
|
|
ce366b3e1a | ||
|
|
144db2add8 | ||
|
|
9e7cc0c585 | ||
|
|
5886025367 | ||
|
|
7cff6c2839 | ||
|
|
c99023d9c6 | ||
|
|
79a727617f | ||
|
|
1484797532 | ||
|
|
c9b212b2f4 | ||
|
|
e8933d3c87 | ||
|
|
bd796359a4 | ||
|
|
5579244598 | ||
|
|
5c0ea823f4 | ||
|
|
23b97f5911 | ||
|
|
749da88afe | ||
|
|
a83949d644 | ||
|
|
b4212cee6b | ||
|
|
9082a8e272 | ||
|
|
d04aed0e6d | ||
|
|
b6465a32a3 | ||
|
|
b425ae5e98 | ||
|
|
fd146050be | ||
|
|
e42dd82ce3 | ||
|
|
7cd132a223 | ||
|
|
60a112cbd0 | ||
|
|
8bfefe9c70 | ||
|
|
f9813f6e0b | ||
|
|
06709cd844 | ||
|
|
e3815934fd | ||
|
|
4daead4b61 | ||
|
|
ccc4825046 | ||
|
|
c655fa94eb | ||
|
|
1b39c5f973 | ||
|
|
cd1e65e06c | ||
|
|
486857acc2 | ||
|
|
f8cec60bc8 | ||
|
|
39363e8a11 | ||
|
|
3dd50632ae | ||
|
|
a84a293452 | ||
|
|
4b0f4e9308 | ||
|
|
960b147eee | ||
|
|
41b708836d | ||
|
|
4bec5ef52f | ||
|
|
f641d06736 | ||
|
|
f83d81d7ab | ||
|
|
1f9a80df2c | ||
|
|
a0f47b7596 | ||
|
|
266e7a3cbc | ||
|
|
219fc64ee8 | ||
|
|
747473641f | ||
|
|
7badd9dc74 | ||
|
|
483aadb750 | ||
|
|
bb7eff5431 | ||
|
|
d42e669b64 | ||
|
|
438357267e | ||
|
|
0cc264f218 | ||
|
|
96bcfe41b5 | ||
|
|
ad714f36dd | ||
|
|
7f8f13a40b | ||
|
|
021179c27e | ||
|
|
2f5ed94a59 | ||
|
|
e736e78017 | ||
|
|
75fa62e60e | ||
|
|
53ab0f2bd0 | ||
|
|
37633091dd | ||
|
|
09f455b103 | ||
|
|
72dd23f0c2 | ||
|
|
bb449eb92a | ||
|
|
097fe2eaeb | ||
|
|
3bca5af830 | ||
|
|
83f349d9ca | ||
|
|
6a91a53d2f | ||
|
|
e71ce0f883 | ||
|
|
965fb32a33 | ||
|
|
1ceccc581b | ||
|
|
3ab19a5b4c | ||
|
|
f267b4ae89 | ||
|
|
e47933458c | ||
|
|
ac1fff02cb | ||
|
|
5efd5f9ddc | ||
|
|
3f61b7a269 | ||
|
|
53c8787cd3 | ||
|
|
b14b8636a3 | ||
|
|
643f9464d5 | ||
|
|
726b4f2e80 | ||
|
|
b2c2f84928 | ||
|
|
1fee458553 | ||
|
|
4964e03b3f | ||
|
|
01ab0897d8 | ||
|
|
1a79e574d6 | ||
|
|
2d2d05ad07 | ||
|
|
9a0dde3b55 | ||
|
|
3a0a768ce3 | ||
|
|
f3f66ac02a | ||
|
|
c053004a89 | ||
|
|
5150fae578 | ||
|
|
31de2d481c | ||
|
|
bc0eef00a7 | ||
|
|
f9390d0d30 | ||
|
|
8ec528591d | ||
|
|
9fadbde0ab | ||
|
|
616165d09a | ||
|
|
f8c6e974ac | ||
|
|
1383f7a4cd | ||
|
|
19edcefa82 | ||
|
|
ed7dc4d333 | ||
|
|
5d5e9836db | ||
|
|
259f9def3d | ||
|
|
93bad2224c | ||
|
|
b2e270c73d | ||
|
|
fdf3de8745 | ||
|
|
3ac56ae66e | ||
|
|
7f75c82753 | ||
|
|
cddecc04cb | ||
|
|
420077585e | ||
|
|
679e5ed1d9 | ||
|
|
5115cc32c6 | ||
|
|
c878a2f516 | ||
|
|
604984a2b3 | ||
|
|
6bd613b358 | ||
|
|
7dbe5a6f3d | ||
|
|
02ce320cab | ||
|
|
dea817458a | ||
|
|
2cf0b99986 | ||
|
|
36d4f7b1f8 | ||
|
|
763006151f | ||
|
|
0ffcf056ae | ||
|
|
8a553db3aa | ||
|
|
f2d35ba098 | ||
|
|
ceb5dee78f | ||
|
|
a687c2fd55 | ||
|
|
75d7cacfda | ||
|
|
aeb5dd3f85 | ||
|
|
24033dbdca | ||
|
|
2976307dd5 | ||
|
|
a528dec5c2 | ||
|
|
a78fe17311 | ||
|
|
e2eb0d67c0 | ||
|
|
a3b78a49a6 | ||
|
|
8f938ba7c1 | ||
|
|
def448f1f8 | ||
|
|
e0f797ae92 | ||
|
|
62d2f8b27c | ||
|
|
473380591a | ||
|
|
75baa7ce48 | ||
|
|
55aea83ff0 | ||
|
|
a6dc277361 | ||
|
|
5cca8457ed | ||
|
|
b99e584bca | ||
|
|
edde73e42a | ||
|
|
6a2eec41ec | ||
|
|
56410f472f | ||
|
|
c3b5caea75 | ||
|
|
56a48cb64a | ||
|
|
d90ca78bd8 | ||
|
|
8ebbc078f0 | ||
|
|
407658ef58 | ||
|
|
0acde518e5 | ||
|
|
99c0aef6bf | ||
|
|
ce2ff32c87 | ||
|
|
a6c0c89b87 | ||
|
|
a6f615aeed | ||
|
|
4c40862da6 | ||
|
|
86c05f630a | ||
|
|
85842442b9 | ||
|
|
ba58e6db9d | ||
|
|
2485548c9f | ||
|
|
90d11bd646 | ||
|
|
259d37c78d | ||
|
|
ebccc6a854 | ||
|
|
e510976526 | ||
|
|
5885b52fd2 | ||
|
|
ba3fb0ae81 | ||
|
|
97acc4cdd3 | ||
|
|
733cb7148b | ||
|
|
a69e1ed555 | ||
|
|
9023d7394a | ||
|
|
0fc3dee643 | ||
|
|
09d57feb74 | ||
|
|
69bbda7368 | ||
|
|
4c22be0a1d | ||
|
|
985463a763 | ||
|
|
c39010ffc0 | ||
|
|
d57e0a21f6 | ||
|
|
50ce7670aa | ||
|
|
950d255ecb | ||
|
|
26591fc031 | ||
|
|
4465ed5f2d | ||
|
|
cb941de499 | ||
|
|
450264fa00 | ||
|
|
ab6608deb7 | ||
|
|
dccecc50ef | ||
|
|
c62cad6be4 | ||
|
|
5c48e1cae9 | ||
|
|
3f3874acbb | ||
|
|
b68269ce11 | ||
|
|
5e01c7d656 | ||
|
|
e7c05f56ff | ||
|
|
0461d71f97 | ||
|
|
f6ca34e4b1 | ||
|
|
02c05c9fc5 | ||
|
|
f4ffb08aff | ||
|
|
d7ff8eff89 | ||
|
|
2caa184e53 | ||
|
|
4bef928c2b | ||
|
|
4eee035b6a | ||
|
|
79c18cb2b0 | ||
|
|
1b7fbeafeb | ||
|
|
82aeba360f | ||
|
|
a555f4ec71 | ||
|
|
ea5219face | ||
|
|
f1f2aecf3d | ||
|
|
ab5778cb42 | ||
|
|
d94d9cfcef | ||
|
|
e1a5e72286 | ||
|
|
0e4aee48bc | ||
|
|
914eda6ebb | ||
|
|
e1d764d053 | ||
|
|
4f2ca2e9f5 | ||
|
|
7e2a9268fd | ||
|
|
d457834f1e | ||
|
|
a8409e59c1 | ||
|
|
a0a47dc6f0 | ||
|
|
67653e73f5 | ||
|
|
aa3dc8c44a | ||
|
|
9940de7278 | ||
|
|
c1f1a4be18 | ||
|
|
7be61340fd | ||
|
|
dc140a6ec2 | ||
|
|
c8913d7039 | ||
|
|
1618ef32b4 | ||
|
|
52fa502259 | ||
|
|
7d6e80977c | ||
|
|
29848eea26 | ||
|
|
25c64ef9f2 | ||
|
|
722bc4b7d9 | ||
|
|
50c28a9d25 | ||
|
|
a5ce39280c | ||
|
|
1a41a86d54 | ||
|
|
237076a075 | ||
|
|
d4365770ed | ||
|
|
34ef9dd8fb | ||
|
|
fe78f38921 | ||
|
|
3907d269f1 | ||
|
|
c323a4f4ad | ||
|
|
3a26fb4918 | ||
|
|
37a83dbf99 | ||
|
|
9c751107e9 | ||
|
|
1606cbf946 | ||
|
|
7a29e5027b | ||
|
|
c4936cbda8 | ||
|
|
405a59c1d4 | ||
|
|
1928b5e328 | ||
|
|
9bd5bbb8c3 | ||
|
|
1f62213175 | ||
|
|
e24054922e | ||
|
|
e81371df4d | ||
|
|
b46c578445 | ||
|
|
218fd3d81c | ||
|
|
d067b74969 | ||
|
|
bfdc1fbc00 | ||
|
|
52e1c77eb4 | ||
|
|
ba38443648 | ||
|
|
82be9ade58 | ||
|
|
f59ae5666a | ||
|
|
248d72a5cb | ||
|
|
4105e1adad | ||
|
|
9a8335a486 | ||
|
|
2e7838c405 | ||
|
|
a364a60d0c | ||
|
|
da5ead75f5 | ||
|
|
50d4f7028d | ||
|
|
edcacd7ad9 | ||
|
|
d21aecd8ec | ||
|
|
b184e7be65 | ||
|
|
437e4916a9 | ||
|
|
e10cfa7779 | ||
|
|
80e313229c | ||
|
|
1754f39e78 | ||
|
|
02bdcddcf5 | ||
|
|
94502e2b54 | ||
|
|
a4718e75c5 | ||
|
|
8008161e3e | ||
|
|
841ce94358 | ||
|
|
5b0977509f | ||
|
|
d6d4efcf79 | ||
|
|
1a5e6d6a0e | ||
|
|
fa2dbcda26 | ||
|
|
0458406ce5 | ||
|
|
9184529b37 | ||
|
|
71d9142a9b | ||
|
|
2167405fa6 | ||
|
|
60b69d1e7d | ||
|
|
beb808bf23 | ||
|
|
ddfa543e40 | ||
|
|
7906187d25 | ||
|
|
4d451079db | ||
|
|
8b153bd18f | ||
|
|
763432f535 | ||
|
|
3a339a0d79 | ||
|
|
1fd936a656 | ||
|
|
fccb0d3b87 | ||
|
|
ff35dddf73 | ||
|
|
5f9224e6c6 | ||
|
|
69825a2c32 | ||
|
|
0cfd72096a | ||
|
|
67ae8351a5 | ||
|
|
010375c2d1 | ||
|
|
80f30914a9 | ||
|
|
7de0457f3d | ||
|
|
f598506d13 | ||
|
|
b78ba9dbe5 | ||
|
|
6ed1a6ded0 | ||
|
|
1b6c45daeb | ||
|
|
83404846cd | ||
|
|
c44f5aac39 | ||
|
|
45b372991f | ||
|
|
fa7893f429 | ||
|
|
eecac4583b | ||
|
|
52c447a72a | ||
|
|
c5957d7259 | ||
|
|
02722f8af6 | ||
|
|
73b7ef63ea | ||
|
|
365a313656 | ||
|
|
50c1614976 | ||
|
|
1118b62ffe | ||
|
|
e736891ffd | ||
|
|
84d40dc01c | ||
|
|
fb2f53e997 | ||
|
|
c512732eb0 | ||
|
|
7d0b957809 | ||
|
|
0459c1d2e5 | ||
|
|
de2d449bba | ||
|
|
15d9faa71f | ||
|
|
6b1abd310a | ||
|
|
5958c545d6 | ||
|
|
492f165435 | ||
|
|
ca5e8e79f8 | ||
|
|
4c84c91ef0 | ||
|
|
7f2ac6b6d8 | ||
|
|
8a12b98df9 | ||
|
|
81cb2d8e15 | ||
|
|
7ba6776647 | ||
|
|
830fdc2539 | ||
|
|
ae18ba9873 | ||
|
|
7d5a989715 | ||
|
|
62582c8ae2 |
@@ -0,0 +1,6 @@
|
||||
data/
|
||||
*.db
|
||||
*.db-*
|
||||
.git/
|
||||
.claude/
|
||||
node_modules/
|
||||
+20
-4
@@ -1,8 +1,19 @@
|
||||
# binaries
|
||||
/iam2
|
||||
# binaries — anchored to the repo root, where `go build` drops them.
|
||||
# Unanchored (`iam`) would match every path component named iam at any depth,
|
||||
# which silently hid pkg/iam/ from git.
|
||||
/iam
|
||||
/iam-v2
|
||||
iam2
|
||||
iam-v2
|
||||
/iamd
|
||||
|
||||
# signing key material — keys live in KMS, never in the tree
|
||||
object/token_jwt_key.key
|
||||
object/token_jwt_key.pem
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# frontend deps + build output
|
||||
node_modules/
|
||||
web/build/
|
||||
|
||||
# base / sqlite data
|
||||
/data
|
||||
@@ -10,9 +21,14 @@ iam-v2
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# committed test fixtures (encrypted-source migrator canvas vector)
|
||||
!cmd/migrate-v1/testdata/*.db
|
||||
|
||||
# env / local
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
# agent scratch — never committed
|
||||
.claude/
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,200 @@
|
||||
name: image
|
||||
# THE builder for ghcr.io/hanzoai/iam from main, on Hanzo's own forge.
|
||||
#
|
||||
# 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
|
||||
# of WORKFLOW_DIRS that exists in the pushed commit and ignores the rest
|
||||
# (modules/actions/workflows.go, listWorkflowsInDirs — it breaks on the first
|
||||
# hit), so on main this directory shadows .github/workflows entirely. The
|
||||
# Casdoor-lineage branches carried no .hanzo/workflows, so when a v* tag on one
|
||||
# of them synced to the mirror, Gitea collected its .github/workflows/build.yml
|
||||
# instead — and that file logs in with hanzo-dev + GH_PAT, which exists as a
|
||||
# git.hanzo.ai org secret. It would therefore SUCCEED, racing GitHub Actions to
|
||||
# push the same immutable ghcr.io/hanzoai/iam:v<X.Y.Z> from the same commit: two
|
||||
# digests behind one name, exactly the platform v4.4.5 incident. Gitea's disable
|
||||
# list is keyed on the workflow FILENAME (services/actions/notifier_helper.go
|
||||
# checks cfg.IsWorkflowDisabled(wf.EntryName)), so `build.yml` was disabled on
|
||||
# this repo to block that legacy builder, and this file carries a distinct name
|
||||
# so the block could not also silence it.
|
||||
#
|
||||
# That second lineage is now DELETED, not disabled: every .github/workflows
|
||||
# builder (build/cicd/release/docker-deploy) was removed from all 133 branches
|
||||
# that carried one, so no tree in this repo can collect a second builder on
|
||||
# either forge. The release line has also converged — v1.33.20…v1.33.31 are all
|
||||
# commits on main. This is the only file in the repo that builds an image.
|
||||
#
|
||||
# WHY THIS FILE EXISTS. `.github/workflows/build.yml` was neutralized to a
|
||||
# dispatch-only echo on 2026-07-24 (f267b4ae8) in favour of a native pipeline
|
||||
# that could never run, so every commit on main since has built nothing
|
||||
# anywhere. Measured on 2026-07-25: main is `diverged` from the v1.33.x line
|
||||
# that actually ships (136 ahead / 142 behind v1.33.8), the last image
|
||||
# v1.33.8 came from a tag push on that OTHER line, and git.hanzo.ai had
|
||||
# Actions disabled on this mirror — zero runs, ever. This is the repair.
|
||||
#
|
||||
# The four defects in the file this replaces, each measured, not guessed:
|
||||
# 1. runs-on: hanzo-linux-amd64 — matches NO registered runner. The four
|
||||
# online act_runners advertise exactly ubuntu-latest, ubuntu-22.04,
|
||||
# ubuntu-24.04, hanzo-build-linux-amd64 (/api/v1/admin/actions/runners).
|
||||
# A job asking for the old label queues forever instead of failing.
|
||||
# 2. buildctl-daemonless.sh — absent from catthehacker/ubuntu:act-24.04, the
|
||||
# image this pool actually serves for hanzo-build-linux-amd64.
|
||||
# 3. secrets.GIT_CLONE_TOKEN — exists on neither the repo nor the org. The
|
||||
# Dockerfile needs a token here: GOPRIVATE=github.com/hanzoai/* means
|
||||
# `go mod download` cannot read hanzoai/orm + hanzoai/sqlite without one.
|
||||
# 4. kubectl patch app iam — the App CR is ArgoCD-managed with selfHeal, so
|
||||
# the patch is reverted on the next poll, and the runner has no
|
||||
# kubeconfig. Rollout is a reviewed tag pin in hanzoai/universe. Not here.
|
||||
#
|
||||
# The image is tagged by COMMIT SHA only. A semver tag that gets re-pushed
|
||||
# leaves two digests behind one name, and with imagePullPolicy: IfNotPresent a
|
||||
# node keeps whichever it cached first — that is how platform's v4.4.5 and
|
||||
# v4.4.6 each came to mean two different builds on 2026-07-25. A SHA cannot move.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A `v*` tag is a RELEASE and must produce an image named after it. Without
|
||||
# this line the builder answered only to branch pushes, so every `git tag`
|
||||
# published nothing: v1.33.32 through v1.33.37 were all cut and none of them
|
||||
# has an image. Production ran `sha-ba43c54` — a commit newer than the last
|
||||
# BUILT release (v1.33.31) and older than two tagged ones — so the estate's
|
||||
# IdP was running code no version names.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: image-iam-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# THE GATE. Not decoration: the GitHub builders this file replaces gated their
|
||||
# push behind tests (docker-deploy.yml's `docker` job declared
|
||||
# `needs: [go-tests, go-build, frontend-build]`), so deleting them without a
|
||||
# gate here would have traded two builders for one UNGATED builder — a strictly
|
||||
# worse posture than the duplication it fixes. `make test` is the repo's single
|
||||
# declared gate (`go test ./... -race -count=1`); it is named here rather than
|
||||
# inlined so a human and CI keep running the identical command.
|
||||
test:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
# GOPRIVATE keeps hanzoai/orm + hanzoai/sqlite off the proxy/sumdb; the
|
||||
# rewrite is what lets `go mod download` actually read them. Same token the
|
||||
# image build mounts as GIT_AUTH_TOKEN — one credential, two consumers.
|
||||
- name: Authenticate private module fetches
|
||||
run: |
|
||||
git config --global url."https://x-access-token:${{ secrets.GH_PAT }}@github.com/".insteadOf "https://github.com/"
|
||||
|
||||
- run: make test
|
||||
env:
|
||||
GOPRIVATE: github.com/hanzoai/*
|
||||
|
||||
build:
|
||||
needs: [test]
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ONLY a `v*` tag publishes. A branch push still BUILDS — that is the
|
||||
# check that main compiles and the image assembles — but it pushes
|
||||
# nothing.
|
||||
#
|
||||
# It used to publish an immutable `sha-<7>` alongside, on the reasoning
|
||||
# that both are traceable and only the tag is deployable. Traceable is not
|
||||
# the bar: a registry that accumulates a tag per commit makes "what is
|
||||
# released" a question you answer by reading git rather than by reading
|
||||
# the registry, and it is how production came to run `sha-ba43c54` — a
|
||||
# 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*)
|
||||
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 "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||
echo "push=$push" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USER }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.meta.outputs.push }}
|
||||
provenance: false
|
||||
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 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.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.
|
||||
secrets: |
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
|
||||
# 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="${{ 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
|
||||
fi
|
||||
echo "manifest not visible yet (attempt $i/6) — retrying"; sleep 5
|
||||
done
|
||||
echo "::error::$img not pullable after push"; exit 1
|
||||
@@ -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
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
|
||||
FROM golang:1.26.5@sha256:3aff6657219a4d9c14e27fb1d8976c49c29fddb70ba835014f477e1c70636647 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache the module graph before copying the source. iam imports private hanzoai
|
||||
# modules (hanzoai/orm, hanzoai/sqlite), so mark them private (direct fetch, no
|
||||
# sumdb) and — when a GIT_AUTH_TOKEN is mounted — rewrite github.com to an
|
||||
# authenticated fetch so `go mod download` can read them. Same pattern as
|
||||
# hanzoai/cloud; without the token it is a no-op (a public-only build still works).
|
||||
ENV GOPRIVATE=github.com/hanzoai/*
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
|
||||
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Per SCALE_STANDARD.md §2 — every Go production Dockerfile that emits JSON to a
|
||||
# client builds with GOEXPERIMENT=jsonv2 (zip's edge JSON path).
|
||||
ARG GO_EXPERIMENT=jsonv2
|
||||
ENV GOEXPERIMENT=${GO_EXPERIMENT}
|
||||
|
||||
ARG VERSION=dev
|
||||
# One binary: the server (/out/iam), pure-Go (CGO_ENABLED=0 + GOEXPERIMENT).
|
||||
#
|
||||
# It used to build a second, /out/migrate-v1 — the Phase-5 cutover migrator. That
|
||||
# command was deleted in 144db2add ("iam: one IAM — drop v1 and the iam2 name")
|
||||
# and this stanza was not, so every image build since has failed at
|
||||
# `stat /src/cmd/migrate-v1: directory not found`. The Dockerfile is the only
|
||||
# consumer that still referenced it.
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/iam .
|
||||
|
||||
FROM alpine:latest@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS STANDARD
|
||||
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam"
|
||||
LABEL org.opencontainers.image.title="Hanzo IAM"
|
||||
# sqlcipher is the C SQLCipher 4.x shell the migrator's --wal-inclusive path drives
|
||||
# to checkpoint each shard's uncheckpointed -wal before extraction; alpine ships
|
||||
# SQLCipher 4.x (4.5.6 on the stable branch, 4.6.x on edge), whose v4 on-disk
|
||||
# format matches the production data and the pure-Go codec. The server never calls
|
||||
# it — it rides along so this ONE image serves both the server and the migrator Job.
|
||||
# alpine is digest-pinned: this runtime base is in the migrator's DEK trust path (it
|
||||
# provides the sqlcipher the raw decryption key is piped to), so a floating :latest is
|
||||
# not acceptable for a one-shot migration of irreplaceable auth data (RED, v1.32.6).
|
||||
RUN apk add --no-cache ca-certificates sqlcipher && update-ca-certificates \
|
||||
&& adduser -D -u 1000 hanzo \
|
||||
&& mkdir -p /data && chown -R hanzo:hanzo /data
|
||||
USER 1000
|
||||
WORKDIR /
|
||||
COPY --from=build --chown=hanzo:hanzo /out/iam /iam
|
||||
|
||||
# Serves the IAM API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
|
||||
# config with --init-data /etc/iam/init_data.json (mounted from the same
|
||||
# init_data ConfigMap the legacy iam uses; ${VAR} creds from the KMS-synced env).
|
||||
EXPOSE 8080 9653
|
||||
ENTRYPOINT ["/iam"]
|
||||
CMD ["serve", "--db", "/data/iam.db", "--http", "http://:8080", "--zap", ":9653"]
|
||||
@@ -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 Casdoor, 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.
|
||||
@@ -0,0 +1,429 @@
|
||||
# LLM.md — hanzoai/iam
|
||||
|
||||
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. 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
|
||||
NOT a language SDK. Clients authenticate via the `@hanzo/iam` SDK, never by
|
||||
hand-rolling OAuth. Full SDK model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
|
||||
|
||||
## Build & run
|
||||
- `go build ./...`
|
||||
- `go run . serve --init-data init_data.json` (SQLite default; `--store sqlite|sql|datastore`)
|
||||
- `go run . compare --legacy postgres://…/iam` (needs `-tags migration`)
|
||||
- Image: `ghcr.io/hanzoai/iam`. Go 1.26.
|
||||
|
||||
## Embedding — a host GRAFTS the app, it does not adapt a handler
|
||||
|
||||
Two entry points, and they are different verbs for different situations:
|
||||
|
||||
| call | what it does | when |
|
||||
|---|---|---|
|
||||
| `server.NewApp(db) *zip.App` | the whole IAM surface as a self-contained app | a host composing IAM in process: `app.Graft(iamserver.NewApp(db))` |
|
||||
| `server.Route(app, db)` | registers IAM's routes ONTO the host's app | only when the host genuinely wants IAM's routes co-mingled with its own. It also brings IAM's root-level routes onto the host, which is what shadowed a host console once |
|
||||
|
||||
`server.Handler(db) http.Handler` is **deleted** (was: `adaptor.FiberApp(NewApp(db).Fiber())`).
|
||||
It existed so a host could hang the whole surface on one wildcard —
|
||||
`app.All("/v1/iam/*", zip.AdaptNetHTTP(iamserver.Handler(db)))` — and that
|
||||
adapter is where IAM's knowledge died. `AdaptNetHTTP` takes an `http.Handler`
|
||||
and returns a closure, so the App went in and a bare function came out, and
|
||||
IAM's **94 typed ops** went with it. hanzoai/cloud published five wildcard path
|
||||
keys and 35 placeholder operations where 78 real paths and 94 typed operations
|
||||
were — no schema, no MCP tool, no CLI command, no SDK method for any of them.
|
||||
|
||||
`zip.Graft` (zip v1.18.16) is the composition that keeps them: the host's router
|
||||
learns IAM's route patterns AND its op registry, while IAM's own router keeps
|
||||
IAM's behaviour — its `Use(authz.Guard)` seam, its error handler, its config.
|
||||
Serving is unchanged and strictly cheaper (no net/http round trip). IAM's
|
||||
`Authorizer` still runs on IAM's ops; the host never re-authorizes them under
|
||||
its own rules. Named types are published as `iam.<Type>`, because a composed
|
||||
document carries more than one app's `Application`.
|
||||
|
||||
**Liveness is not IAM's.** `/healthz`, `/readyz` and `/metrics` are zip's ops
|
||||
surface (HIP-0119 §1) — a SECOND listener the DEPLOYMENT brings up when it names
|
||||
`OPS_PORT`, never the public one. IAM used to register `/healthz` on its public
|
||||
group; that was hand-rolling a path the framework owns, on the wrong listener,
|
||||
and it is also what made IAM un-composable: a host registers `/healthz` as the
|
||||
HOST's, because it must answer while every subsystem is still cold. Two
|
||||
claimants on one liveness address is what once served `{"binary":"iam2"}` out of
|
||||
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/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).
|
||||
|
||||
## A principal is `owner`/`name` — org and USERNAME, on every surface
|
||||
|
||||
**The rule.** `owner` is the org. `name` is the USERNAME (`<name>` of
|
||||
`<owner>/<name>`). A display name never appears in `name`, in a token or in
|
||||
UserInfo; it has its own claim, `displayName`. `preferred_username` is the
|
||||
OIDC-standard spelling of the same username and is sourced from the same field,
|
||||
so the two cannot drift.
|
||||
|
||||
**Why.** `hanzo auth login` files its credential under the token's own
|
||||
`owner`/`name`, so those claims ARE the principal downstream believes it holds.
|
||||
`userClaims` computed `name = DisplayName, else Name` — OIDC's display reading of
|
||||
`name`, inherited from the v1 `Userinfo` struct and present since the in-tree
|
||||
server was written (`73b7ef63e`). Measured on iam.hanzo.ai 2026-07-30: a login as
|
||||
account `z` minted `name: "Zach Kelling"` and the CLI filed `hanzo/Zach Kelling`,
|
||||
an account that does not exist. cloud's money path had already paid for the same
|
||||
reading — it addresses a wallet `<org>/<username>`, addressed `hanzo/Zach Kelling`
|
||||
and 402'd every completion while the balance sat in `hanzo/z`. `5c0ea823f`
|
||||
answered that by ADDING `preferred_username` and deliberately leaving `name`
|
||||
display-sourced, which gave the username a home without evicting the display name
|
||||
from the claim consumers actually read; the CLI then hit the wall from the other
|
||||
side. One address for a principal beats two spellings that disagree, so `name` is
|
||||
the username and OIDC's display reading of it is the thing we diverge from.
|
||||
|
||||
**One resolution, one claim builder.** Three mint paths — the code/refresh/password
|
||||
grant, the console's issue-user-token, and the RFC 8693 exchange — had each
|
||||
SEPARATELY written the `DisplayName, else Name` fallback, so fixing one would have
|
||||
left two. `identityOf` is now the only user→claims resolution and `Signer.claims`
|
||||
the only place an `Identity` becomes a claim set. The values also stopped
|
||||
travelling as six adjacent positional strings: two of them are human-readable and
|
||||
were therefore swappable at the call site, they WERE swapped on all three paths,
|
||||
and it type-checked (the wallet harness had lost a scope into the username slot
|
||||
the same way). UserInfo answers identically — it and the token describe one
|
||||
principal, and a client holding either must not get two names for it.
|
||||
|
||||
## Usernames — one rule, at the write
|
||||
|
||||
`schema.Username`: trim, lowercase, `^[a-z0-9][a-z0-9._-]{0,62}$`. Normalization
|
||||
settles case and padding; everything else is REFUSED rather than rewritten,
|
||||
because quietly turning what someone typed into a different principal is the
|
||||
failure being avoided. One character is legal (the account this was written over
|
||||
is `z`); a leading digit is legal (nothing resolves a principal numerically).
|
||||
|
||||
Ten entry points reach a user row and exactly ONE used to validate the name it
|
||||
wrote. The rule now lives at `users.Create` — the choke point six of them share —
|
||||
plus the three that write through orm directly (bootstrap's first-admin seed, the
|
||||
wallet identity, the onboarding credential). `CreateInput.AuthzTarget` normalizes
|
||||
too, so the pair AUTHORIZED is the pair STORED. Service accounts keep only what is
|
||||
theirs: `<org>-` binding and segmentation.
|
||||
|
||||
**Social signup derives from the ADDRESS, never the profile.** `schema.Handle`
|
||||
takes the email local part and refuses a string with no `@` or a local part with
|
||||
whitespace — without both, "Zach Kelling" is a local part whose space gets dropped
|
||||
and the profile name silently becomes the username `zachkelling`. Dedupe is a
|
||||
numeric suffix (`z`, `z2`, `z3`), replacing a random 8-hex suffix on every name
|
||||
that made collisions impossible by making every username unrecognisable.
|
||||
|
||||
**Case does not make a second person, and stored names are NOT rewritten** —
|
||||
renaming moves real principals. `store.GetUserByName` resolves exact, then folded,
|
||||
then over the org for a legacy mixed-case row, and FAILS CLOSED on an ambiguous
|
||||
fold (the rule `GetUserById` already applies to a duplicated subject), so whoever
|
||||
registered "ALICE" alongside "Alice" is never resolved as the other. `users.lookup`
|
||||
goes through it rather than repeating the query — restating it is how Create's
|
||||
uniqueness check stayed case-SENSITIVE while the rule it guards is not.
|
||||
|
||||
## Org scope — HONOURED or REFUSED, never silently reinterpreted
|
||||
|
||||
**The rule.** A request that NAMES an organization gets that organization's data
|
||||
or an error. It never gets a different organization's data. `authz.Scope` is the
|
||||
one place it lives; all 17 org-scoped call sites resolve their owner there.
|
||||
|
||||
| principal | `?owner=` | result |
|
||||
|---|---|---|
|
||||
| SuperAdmin (org `admin`) | anything | honoured; empty = every tenant |
|
||||
| anyone else | absent | own org (unstated ≠ reinterpreted) |
|
||||
| anyone else | its own org | honoured |
|
||||
| anyone else | **any other org** | **403, no rows** |
|
||||
| anyone else, `p.Org == ""` | anything | **403** (no org ⇒ no scope; `""` used to mean *no filter* = every tenant) |
|
||||
|
||||
**Why.** `Scope` used to `return p.Org` for ANY owner. Measured in production
|
||||
2026-07-28 with the `hanzo-console` credential (home org `hanzo`): `?owner=lux`,
|
||||
`?owner=zoo` and `?owner=nonexistent-org-xyz` each answered `200 {"status":"ok"}`
|
||||
with 262 **`hanzo`** accounts. Nothing in the code, the `status` field, the `msg`
|
||||
or the count said the filter had been dropped, so a fabricated org was
|
||||
indistinguishable from a real one *and* from your own. No rows escaped IAM, so it
|
||||
was not a confidentiality breach here — it was **misattribution**, which is worse
|
||||
in one specific way: you believe you hold tenant B while holding tenant A. It
|
||||
nearly caused a production purge of the wrong tenant. Downstream it *was* a leak:
|
||||
cloud's IAM edge (`cloud/iam_edge.go`) validates `?owner=` against the calling
|
||||
tenant and then forwards it under ONE confidential client, so every tenant's team
|
||||
page asked for its own org and was served the edge credential's org.
|
||||
|
||||
**Not an existence oracle — by construction, not by care.** The refusal is decided
|
||||
from the verified principal alone and never touches the store, so `lux` (real),
|
||||
`built-in` (reserved) and `nonexistent-org-xyz` (invented) are the same comparison
|
||||
and the same bytes; the message names the CREDENTIAL's org, never the requested
|
||||
one. Same collapse cloud's per-org KMS store makes: every spelling you may not
|
||||
have routes to ONE existence-independent answer. It differs only in *which*
|
||||
answer — KMS reads the org from the token, so absence is its only observable and
|
||||
it answers 404; here the org is a stated parameter, so there is a decision to
|
||||
report and reporting it is the point.
|
||||
|
||||
**Cross-tenant reach exists only where a grant says so**, and a grant HONOURS the
|
||||
org it names (returning that org's real data, correctly attributed) — it never
|
||||
substitutes:
|
||||
- **SuperAdmin** — every entity. The only unrestricted cross-tenant scope.
|
||||
- **`CapOrgAdmin`** — the organization REGISTRY only. Brand consoles create
|
||||
customer orgs during onboarding and read `Organization.Founder` to resume a
|
||||
partial one, so registry-wide reach is load-bearing, not incidental.
|
||||
|
||||
So `get-users` and `get-organization` now **agree on the only question carrying a
|
||||
secret**: for every principal without a cross-tenant grant both refuse a foreign
|
||||
org existence-independently, so neither is an oracle. For a `CapOrgAdmin` holder
|
||||
org existence is *not* a secret — it can create orgs and read `Founder`, so hiding
|
||||
reads from it would be theatre. What can no longer happen anywhere: **being handed
|
||||
org A's rows in answer to a request that named org B.**
|
||||
|
||||
**A rewrite is not a safe answer, only an unsampled one.** The old SCIM guard
|
||||
(`scim/read_scope_test.go`) proved foreign-exists and foreign-missing were both
|
||||
404 and called the oracle closed. It was: the re-pin turned `/Users/orgb/bob` into
|
||||
a lookup of `hanzo/bob`, absent. Seed a `hanzo/bob` — a name every tenant has —
|
||||
and the same request returns **200 carrying hanzo's bob under orgb's URL**, and
|
||||
`PATCH active:false` then deactivates a hanzo employee. Pinned by
|
||||
`TestRed_scimGet_foreignIdNeverResolvesToASameNamedLocalUser`.
|
||||
|
||||
**Divergence still open (needs a decision, do not "fix" by widening).** The legacy
|
||||
verb lister goes through `Scope`; the native noun lister (`organizations.List`)
|
||||
filters `in.Owner` under the Guard's authorization. For a `CapOrgAdmin` app,
|
||||
`get-organizations?owner=admin` is now a 403 while `/v1/iam/organizations?owner=admin`
|
||||
returns every org row (masked). Before this change it was 403-vs-a-silently-EMPTY
|
||||
list, so nothing regressed — but one policy still answers two ways on two
|
||||
spellings. Unifying it changes a documented capability's blast radius: decide it
|
||||
deliberately, in `authorize()`, not by opening the legacy lister.
|
||||
|
||||
## API keys — one entity, one plural noun, and the SCOPE is what differs
|
||||
|
||||
`internal/keys`, entity `keys`, routes `/v1/iam/keys{,/get,/update,/delete}`.
|
||||
**Plural on every op** (like `users`): `authz.entityOf` reads the FIRST path segment
|
||||
as the entity, so serving the list at `keys` and the writes at `key` made two entity
|
||||
strings for one entity — and any capability keyed on it was dead on whichever half
|
||||
you did not name. Same defect `entityNoun` fixes for the legacy verb spellings.
|
||||
|
||||
`Scope` is the ACCESS CLASS, fixed at create (an update that could flip it would
|
||||
blank a secret and open the ingest door):
|
||||
|
||||
| scope | halves | resolves to | door |
|
||||
|---|---|---|---|
|
||||
| `""` (secret) | `pk-` + `sk-` | the USER | `get-user?accessKey=` (`CapKeyResolve`) |
|
||||
| `publish` | `pk-` only, NO secret | just the ORG | `resolve-key` (`CapPublishableResolve`) |
|
||||
|
||||
**The publishable key had no producer until 2026-07-28.** The model, the resolver
|
||||
(`store.PublishableKeyByAccessKey`) and the ingest door all existed and nothing
|
||||
minted one. It is now a FIELD on the one mint:
|
||||
`POST /v1/iam/mint-user-keys?id=<owner>/<name>&type=publishable|secret` (default
|
||||
secret; unknown type → 400), same for `revoke-user-keys`. `keys.NameFor(scope)` maps
|
||||
scope → row name (`cloud-api` / `publishable`), so the two are separate rows and
|
||||
rotating a browser key does not revoke the API key.
|
||||
|
||||
**Every read is masked.** `schema.Key.Mask()` blanks `AccessSecret` and keeps
|
||||
`AccessKey` (a `pk-` is public and its holder needs it). Before it, the key list
|
||||
handed every reader every secret in the org, which made read AUTHORIZATION stand in
|
||||
for redaction. The secret is revealed ONCE, by `create`. `capFor("keys")` =
|
||||
`CapKeyMint`: the authority that already mints a credential may read the set it
|
||||
manages.
|
||||
|
||||
`MintUserKey` writes a `schema.Key` ROW because that is the only thing the resolvers
|
||||
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
|
||||
|
||||
Two independent defects made `refresh_token` unusable for every client that signs
|
||||
in through a browser, so a session died at the access token's expiry and the user
|
||||
logged in again. Measured on `hanzo-cli` 2026-07-31: a live refresh answered
|
||||
`401 invalid_client`, and the refresh token was already expired anyway.
|
||||
|
||||
**Client auth.** `authorizationCodeGrant` has a documented relaxation — a
|
||||
registration that HOLDS a secret still serves a public PKCE surface (`hanzo-cli`,
|
||||
and every `@hanzo/iam` SPA whose secret exists for a backend path), so a code
|
||||
exchange that presents no secret is authenticated by PKCE instead.
|
||||
`refreshTokenGrant` did not have it and demanded the secret unconditionally: the
|
||||
client completed the exchange without one, cannot acquire one, and is refused the
|
||||
moment it tries to renew. The fact is now recorded where it belongs — on the
|
||||
GRANT, `schema.Token.PublicGrant`, set at establishment and carried across
|
||||
rotation (drop it and only the FIRST refresh works). It never widens: a grant
|
||||
established WITH the secret still needs it, and a presented secret is always
|
||||
verified.
|
||||
|
||||
**Lifetime.** `refreshTTL` falls back to `appTTL` when `RefreshExpireInHours` is
|
||||
unset — v1 parity, and dead on arrival: the refresh token expires at the same
|
||||
instant as the token it renews. Nothing could say otherwise, because the upsert
|
||||
body carried no lifetime field at all. `expireInHours` / `refreshExpireInHours`
|
||||
now travel document → `provision.App` → upsert → model under ONE name, as
|
||||
POINTERS on the wire so an omitted lifetime PRESERVES (a plain float would reset
|
||||
every app on every converge). `provision.checkLifetimes` REFUSES a refresh
|
||||
lifetime that does not outlive the access lifetime, measured against
|
||||
`schema.DefaultExpireInHours` when the access lifetime is unstated — so the state
|
||||
`hanzo-cli` shipped in cannot be declared again.
|
||||
|
||||
**Which half bit whom** (measured over all 286 live applications on hanzo.id).
|
||||
Most first-party clients already carried `expireInHours: 168` +
|
||||
`refreshExpireInHours: 720` from the v1 era, so for `hanzo-cloud`, `hanzo-chat`,
|
||||
`hanzo-platform`, `hanzo-world` the LIFETIME was fine and only the CLIENT-AUTH
|
||||
half was broken — they held a 30-day refresh token they could not spend. One fix
|
||||
unblocks all of them: driven live after the change, each does code→token 200 then
|
||||
refresh 200 with a new access token, presenting no secret at either step.
|
||||
`hanzo-cli` was the rare client with BOTH lifetimes at 0, which is why it was the
|
||||
one that hurt. Still at 0, and therefore still dead on arrival: `hanzo-mcp` (now
|
||||
declared, same as the CLI), `hanzo-git`, `hanzo-zrok`, `hanzo-admin`, and every
|
||||
auto-created per-signup `app-<email>` client. The fix is one line per app in that
|
||||
org's provision document; it is deliberately NOT a changed global default,
|
||||
because session lifetime is POLICY and this mechanism ships no policy.
|
||||
|
||||
## Device grant — a CLI holds NO secret, and ROPC must then refuse it
|
||||
|
||||
`hanzo auth login` died on `invalid_client: client authentication failed`
|
||||
straight out of `POST /v1/iam/oauth/device`, for every client, so nobody could
|
||||
sign in from a terminal.
|
||||
|
||||
**Cause.** `deviceHandler` requires the stored secret from any registration that
|
||||
HAS one (RFC 8628 §3.1 → 6749 §3.2.1), and every Hanzo client was declared
|
||||
`type: confidential` in the provision document — deliberately, because
|
||||
`client_credentials` and the password grant authenticate with that secret and a
|
||||
public upsert DELETES it (`bootstrap.resolveSecret`). So all 12 held one, and a
|
||||
CLI can never present one. The code exchange survived the same registration
|
||||
shape only because `authorizationCodeGrant` skips client auth when a PKCE
|
||||
challenge is present; the device grant carries no challenge to skip on, so it
|
||||
had no such escape. `invalid_client` distinguishes the two cases — `client_id is
|
||||
invalid` means unknown, `client authentication failed` means known-and-holds-a-
|
||||
secret — which is how the cause was read straight off the wire.
|
||||
|
||||
**Fix.** `hanzo-cli` is `type: cli` in the universe provision document: PUBLIC,
|
||||
no stored secret, loopback redirects per RFC 8252 §7.3, with the device grant
|
||||
declared through the additive `grants:` field rather than added to
|
||||
`grantsByType[cli]` — same reason `redirects` is additive, a type default would
|
||||
silently hand RFC 8628 to every future CLI client in every org. No image was
|
||||
needed; `make iam-provision` converged it.
|
||||
|
||||
**The rule this forced.** Going public silently opened ROPC. `passwordGrant`
|
||||
gates on the `enablePassword` FLAG, not on `grantTypes`, so removing `password`
|
||||
from the document changed nothing — and the grant had a legacy-parity relaxation
|
||||
that let a public client through, carried so console/chat would not 401 during
|
||||
the cutover. With no stored secret and no PKCE challenge and no human approval
|
||||
step, "public" there means anyone who knows the client_id can post a username and
|
||||
password. `passwordGrant` now REFUSES a client with no stored secret. The
|
||||
relaxation was dormant (every live registration is confidential and takes the
|
||||
secret path), so nothing that worked broke. The rule lives on the GRANT, not in a
|
||||
document, because registration shape must not be able to open a credential
|
||||
surface — the same lesson as `Token.PublicGrant` above, in the other direction.
|
||||
|
||||
**One client id.** `hanzo-cli` is the id BOTH CLIs authenticate as — Rust
|
||||
`hanzoai/cli` (`src/iam/oauth.rs` `CLIENT_ID`) and the Go control CLI
|
||||
(`hanzoai/cloud` `cli/cli.go` `defaultClientID`). The Go one had been borrowing a
|
||||
different first-party client per flow (`hanzo-app` for device, `hanzo-console`
|
||||
for password and refresh); besides being unregistrable, that guaranteed renewal
|
||||
could never work, because a device_code is redeemable only by the client it was
|
||||
issued to and a refresh token was being presented under a different id.
|
||||
|
||||
## Key entry points
|
||||
- `main.go` — cobra root (`serve` / `compare` / `version`); `server/server.go` route registration.
|
||||
- `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
|
||||
`enableSignUp: true` with `organization: hanzo` (universe
|
||||
`infra/k8s/iam/init_data.json`). `signupHandler` files the new user under
|
||||
`f.Organization`, and `store.MemberOrgRefs` emits `user.Owner` as the HOME entry
|
||||
of the `orgs` claim — so **anyone on the internet who signs up at hanzo.id is a
|
||||
signed `member` of the `hanzo` tenant** until they onboard. Cloud reads that
|
||||
claim as tenancy (correctly — it is the signed membership set), so a
|
||||
60-second-old anonymous account gets, verified against production 2026-07-28:
|
||||
|
||||
- `/v1/projects`, `/v1/sites` — read **and** write **and** DELETE (a probe
|
||||
project was created and deleted inside org `hanzo`);
|
||||
- `/v1/git/repos` — **121 private repos** listed (`cloud`, `universe`, `ci`,
|
||||
`console`…), and `/v1/git/repos/<name>/tree` returns their file entries;
|
||||
- `/v1/crm/contacts` — read + write (PII).
|
||||
|
||||
Refused: KMS, `/v1/admin/authors` (SuperAdmin), `/v1/iam/keys`; the billing
|
||||
ledger is per-account so no money crosses.
|
||||
|
||||
The asymmetry is the whole bug, and this repo already states the rule that
|
||||
closes it — `provision` (onboard.go) refuses an existing org the caller did not
|
||||
found ("an existing one is refused by the create-conflict check"), while
|
||||
`signup` happily joins one. Two doors to the same end state, one locked.
|
||||
|
||||
NOT fixed unilaterally: every candidate fix trades off badly without an owner
|
||||
decision. Turning `enableSignUp` off on those apps closes it instantly but stops
|
||||
customer signup; note also that `server.Seed` is **new-only**, so editing
|
||||
`init_data.json` does NOT change a live app row — remediation must go through
|
||||
`update-application`, which then needs a GitOps record. The durable fix is to
|
||||
stop reading storage `Owner` as membership (`MemberOrgRefs`), with
|
||||
`BackfillMemberships` already writing the explicit rows that would replace it.
|
||||
Decide, then do it in ONE place.
|
||||
|
||||
## Brand rules (hard)
|
||||
- Never call Hanzo an "LLM gateway"; never position vs LiteLLM. Full AI cloud, not a proxy.
|
||||
- `/v1/` only, never `/api/`. Zen models are our own family — never name upstream models.
|
||||
- White-label by domain; never the Hanzo mark on a Lux/Zoo surface.
|
||||
@@ -1,80 +0,0 @@
|
||||
# IAM v2 Migration
|
||||
|
||||
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and drift-gated —
|
||||
the identity binary is never rewritten in one shot.
|
||||
|
||||
## §1 Why
|
||||
|
||||
`hanzoai/iam` is a fork of Casdoor. Every file carries `Portions Copyright The
|
||||
Casdoor Authors` under Apache-2.0. It couples us to xorm's fluent API, Beego's
|
||||
router, and an upstream we do not control. `iam2` is original expression on our
|
||||
own framework — we own it, and it collapses to one way of doing each thing.
|
||||
|
||||
## §2 Stack contract
|
||||
|
||||
- **HTTP** — `github.com/zap-proto/zip` (typed `zip.Get[In,Out]` handlers on the
|
||||
`zap-proto/fiber/v3` engine, specificity routing, OpenAPI 3.1 at the edge).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache) over
|
||||
`github.com/hanzoai/base` (collections, realtime, replicate-to-S3). SQLite —
|
||||
never Postgres for the local/default path.
|
||||
- **Authz** — `github.com/hanzoai/authz`, one canonical policy engine, called
|
||||
over ZAP RPC. No in-process copy.
|
||||
- **OIDC/OAuth2** — in-tree port (no external OIDC library). ML-DSA-65 hybrid
|
||||
JWT signing; JWKS cache.
|
||||
- **Inter-service** — `github.com/luxfi/zap` binary RPC. HTTPS is the external
|
||||
edge only; all service↔service is ZAP (platform law).
|
||||
|
||||
## §3 Phases
|
||||
|
||||
| Phase | Scope | Gate to exit |
|
||||
|------:|-------|--------------|
|
||||
| 0 | Scaffold: Base boots, v2 collection namespace claimed, `/v1/iam/v2/health`, `compare` CLI. | Binary builds and boots. |
|
||||
| 1 | Entity schemas (fields + indexes) + CRUD handlers on `zip` + `orm`, per resource. | Per-entity field parity vs v1; handlers pass tests. |
|
||||
| 2 | In-tree OIDC/OAuth2 server: `/v1/iam/oauth/*`, `/v1/iam/.well-known/*`, JWT (ML-DSA-65), JWKS. | Token/userinfo/authorize parity vs v1. |
|
||||
| 3 | Authz via `hanzoai/authz` over ZAP RPC; retire in-process authz. | Policy decisions match v1. |
|
||||
| 4 | Parity: run `iam2 compare` continuously against a v1 read replica. | **drift = 0** (or a known v1-only residual v2 does not model). |
|
||||
| 5 | Cutover: import v1 data, promote `iam2` to the `iam` mount, archive the fork. | Green in prod; rollback path proven. |
|
||||
|
||||
Phases 0–4 are additive and non-destructive — v1 stays live and authoritative
|
||||
until Phase 5. Routes carry a `/v1/iam/v2/*` prefix through the transition so
|
||||
they are orthogonal to the live `/v1/iam/*` mount; the prefix collapses at §6.
|
||||
|
||||
## §4 Domain model (v1 xorm table → v2 Base collection)
|
||||
|
||||
Thirteen identity entities. Field-completeness is mandatory — a dropped column
|
||||
is lost auth data.
|
||||
|
||||
| v1 table (xorm) | v2 collection (Base) | Base kind |
|
||||
|-----------------------|------------------------|-----------|
|
||||
| `user` | `users` | auth |
|
||||
| `organization` | `organizations` | base |
|
||||
| `application` | `applications` | base |
|
||||
| `provider` | `providers` | base |
|
||||
| `role` | `roles` | base |
|
||||
| `permission` | `permissions` | base |
|
||||
| `cert` | `certs` | base |
|
||||
| `key` | `keys` | base |
|
||||
| `webauthn_credential` | `webauthn_credentials` | base |
|
||||
| `session` | `sessions` | base |
|
||||
| `token` | `tokens` | base |
|
||||
| `record` | `audit_logs` | base |
|
||||
| `invitation` | `invitations` | base |
|
||||
|
||||
**Deliberately not modeled by iam2** (they belong to commerce/other services,
|
||||
not identity): `payment`, `plan`, `product`, `subscription`, `pricing`,
|
||||
`model`, `adapter`, `enforcer`, `syncer_*`.
|
||||
|
||||
## §5 Drift gate
|
||||
|
||||
`iam2 compare --legacy <v1-dsn>` opens the v1 database **read-only** (only
|
||||
`SELECT COUNT(*)`), opens the v2 Base store read-only, and prints per-entity
|
||||
row counts plus absolute drift. This is the gate that keeps cutover honest:
|
||||
drift must be 0 before Phase 5 import goes live. No writes, no DDL, ever.
|
||||
|
||||
## §6 Cutover
|
||||
|
||||
At Phase 5, with drift proven 0: import v1 rows into v2 collections, drop the
|
||||
`/v2` route prefix so `iam2` answers on `/v1/iam/*`, repoint the `iam` image /
|
||||
operator CR / DNS to `iam2`, and archive `hanzoai/iam`. One identity binary,
|
||||
one way, no Casdoor.
|
||||
@@ -0,0 +1,34 @@
|
||||
# The test gate. One command, run identically by a human and by CI.
|
||||
#
|
||||
# Not a bare `go test ./...`: that reuses cached PASS results, so a stale build
|
||||
# can report green for code you just changed, and it runs without the race
|
||||
# detector, which is where this repo's store and session defects actually show
|
||||
# up. -count=1 defeats the cache; -race is the point.
|
||||
|
||||
.PHONY: test build fmt vet generate
|
||||
|
||||
# Prose reaches the document ONLY through this step. Go drops comments at compile
|
||||
# time, so an operation's description cannot be read off the running binary: the
|
||||
# doc comment on each typed handler is lifted here into the package's
|
||||
# zipdoc_gen.go, which registers it with zip.Describe at init. That file is
|
||||
# COMMITTED, because a consumer building this module does not run go generate.
|
||||
#
|
||||
# Skipping it does not fail loudly — it publishes an operationId and silence, in
|
||||
# the OpenAPI document, the MCP tool list and every generated client and CLI. So
|
||||
# `test` runs zipdoc -check first: a doc comment edited without regenerating is a
|
||||
# red build, not a quietly stale artifact.
|
||||
generate: ## Lift every typed handler's doc comment into its zipdoc_gen.go.
|
||||
go generate -run zipdoc ./...
|
||||
|
||||
test: ## Run the full suite — the gate. Everything must be green to ship.
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' . | xargs -n1 dirname | sort -u); do (cd $$d && go run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: make generate"; exit 1; }; done
|
||||
go test ./... -race -count=1
|
||||
|
||||
build: ## Build every package.
|
||||
go build ./...
|
||||
|
||||
fmt: ## Format.
|
||||
go fmt ./...
|
||||
|
||||
vet: ## Vet.
|
||||
go vet ./...
|
||||
@@ -1,38 +1,123 @@
|
||||
# Hanzo IAM v2
|
||||
# Hanzo IAM
|
||||
|
||||
Proprietary identity service for the Hanzo platform. A clean-room rewrite of the
|
||||
identity layer on the native Hanzo stack — **no Casdoor, no Beego, no xorm**.
|
||||
**Identity & access for the Hanzo cloud — OpenID Connect / OAuth2 with PKCE, standards only.**
|
||||
|
||||
The predecessor (`hanzoai/iam`) is a fork of Casdoor (Apache-2.0). `iam2` owns
|
||||
its source outright: original expression on our own framework and ORM, so the
|
||||
identity binary carries no upstream copyright or license obligations.
|
||||
  
|
||||
|
||||
Hanzo IAM is the identity service behind every Hanzo sign-in: OpenID Connect
|
||||
discovery, the authorize + token endpoints (authorization code + PKCE, refresh,
|
||||
`client_credentials`, RFC 8693 token exchange), UserInfo, JWKS, SCIM 2.0
|
||||
provisioning, MFA / WebAuthn, service accounts, and social federation
|
||||
(Google, GitHub).
|
||||
|
||||
It is a **clean-room, native rewrite** on the Hanzo stack — `zip` over
|
||||
`hanzoai/orm`, **no the legacy surface, no Beego, no xorm**. The identity binary owns its
|
||||
source outright and collapses to one way of doing each thing. The retired
|
||||
the legacy surface/Beego fork lives at
|
||||
[`hanzoai/iam-v1`](https://github.com/hanzoai/iam-v1) and is out of every graph.
|
||||
|
||||
Clients never hand-roll OAuth. They authenticate through the **`@hanzo/iam`
|
||||
SDK** against the endpoints below — one way, no legacy paths (HIP-0111).
|
||||
|
||||
## Stack
|
||||
|
||||
| Concern | Component | Notes |
|
||||
|----------------|-----------|-------|
|
||||
| HTTP | [`zap-proto/zip`](https://github.com/zap-proto/zip) | Typed handlers (`zip.Get[In,Out]`) on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 |
|
||||
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) over [`hanzoai/base`](https://github.com/hanzoai/base) | Typed Go records + KV cache; collections + realtime + replicate-to-S3; SQLite (no Postgres) |
|
||||
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
|
||||
| OIDC / OAuth2 | in-tree | ML-DSA-65 hybrid JWT; no external OIDC library |
|
||||
| Inter-service | `luxfi/zap` | Binary RPC. HTTPS is the external surface only |
|
||||
| Concern | Component | Notes |
|
||||
|---|---|---|
|
||||
| HTTP | [`zap-proto/zip`](https://github.com/zap-proto/zip) | Typed `zip.Get[In,Out]` handlers on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 at the edge |
|
||||
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) | Typed Go records + KV cache over one `orm.DB` abstraction. Embedded SQLite by default (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres |
|
||||
| OIDC / OAuth2 | in-tree | RS256 today; ML-DSA-65 hybrid JWT + real JWKS from the Cert entity. No external OIDC library |
|
||||
| Password verify | `internal/cred` | Algorithm resolved from the stored row — argon2id + bcrypt, verify-only, fail-closed |
|
||||
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
|
||||
| Inter-service | [`zap-proto`](https://github.com/zap-proto) | Binary RPC service↔service. HTTPS is the external edge only |
|
||||
|
||||
## Status
|
||||
## Endpoints — RFC / OIDC standard (no `/api/`, no vendor verbs)
|
||||
|
||||
Phase 0. The binary boots Base, registers the v2 collection schema, serves
|
||||
`/v1/iam/v2/health`, and ships a read-only drift CLI. Cutover off `hanzoai/iam`
|
||||
is gated on `iam2 compare` reading **drift = 0** against a v1 replica.
|
||||
The HTTP contract is RFC/OpenID-standard only. There are no legacy verb aliases
|
||||
(`get-users`, `add-user`, `issue-user-token`, …) and no `/api/` prefix — `/v1/`
|
||||
throughout. Paths are relative to the brand `serverUrl`.
|
||||
|
||||
See [MIGRATION.md](./MIGRATION.md) for the full phased plan.
|
||||
| Capability | Standard | Endpoint |
|
||||
|---|---|---|
|
||||
| Discovery / AS metadata | RFC 8414 · OIDC Discovery | `/.well-known/openid-configuration` |
|
||||
| JWKS | RFC 7517 | `/v1/iam/.well-known/jwks` |
|
||||
| Authorize | RFC 6749 (code + PKCE `S256`) | `/v1/iam/oauth/authorize` |
|
||||
| Token | RFC 6749 (code, refresh, `client_credentials`, password) | `/v1/iam/oauth/token` |
|
||||
| Token exchange / on-behalf-of | RFC 8693 | `/v1/iam/oauth/token` (`grant_type=…token-exchange`) |
|
||||
| Introspection / revocation | RFC 7662 / RFC 7009 | `/v1/iam/oauth/{introspect,revoke}` |
|
||||
| UserInfo (account claims) | OIDC UserInfo | `/v1/iam/oauth/userinfo` |
|
||||
| Logout | OIDC RP-initiated logout | `/v1/iam/oauth/logout` |
|
||||
| Identity provisioning | SCIM 2.0 (RFC 7644 / 7643) | `/v1/iam/scim/v2/Users` |
|
||||
| Social sign-in / federation | OIDC/OAuth2 Relying Party | `/v1/iam/oauth/authorize?provider=<name>` → `/v1/iam/oauth/callback` |
|
||||
|
||||
PKCE `S256` always; `client_secret_basic`; scopes `openid profile email`.
|
||||
`client_id` is `<org>-<app>` (globally unique); `redirectUris` must be the
|
||||
framework's exact callback.
|
||||
|
||||
**Brands** (set `serverUrl`): hanzo → `iam.hanzo.ai` · lux → `lux.id` ·
|
||||
zoo → `zoo.id` · bootnode → `id.bootno.de` · pars → `pars.id`. Shared infra
|
||||
white-labels by domain — never the Hanzo mark on a Lux or Zoo surface.
|
||||
|
||||
## Storage — one `orm.DB`, backend-pluggable
|
||||
|
||||
Every handler and the drift tool are written once against `orm.DB`, never a
|
||||
driver. Pick the backend at boot with `--store`:
|
||||
|
||||
- `sqlite` (default) — embedded, pure-Go, WAL. No Postgres.
|
||||
- `sql` — `hanzoai/sql` over ZAP.
|
||||
- `datastore` — `hanzoai/datastore` over ZAP (ZAP-native persistence +
|
||||
snapshots, zero code change).
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
go build ./...
|
||||
go run . serve # Base + v2 schema + /v1/iam/v2/health
|
||||
go run . compare --legacy postgres://…/iam # read-only v1 ↔ v2 drift report
|
||||
|
||||
# Seed real config + serve OIDC / login (SQLite by default)
|
||||
go run . serve --init-data init_data.json
|
||||
|
||||
# ZAP-native persistence instead of embedded SQLite
|
||||
go run . serve --store datastore --init-data init_data.json
|
||||
|
||||
# Read-only v1 → v2 drift report (needs a `-tags migration` build)
|
||||
go run . compare --legacy postgres://…/iam
|
||||
|
||||
go run . version
|
||||
```
|
||||
|
||||
`serve` flags: `--store` (`sqlite|sql|datastore`), `--db` (SQLite path),
|
||||
`--zap` (ZAP listen), `--http` (HTTP edge), `--init-data` (new-only seed;
|
||||
`${VAR}` expands from env). Deploy env: `IAM_ISSUER=https://<brand-id>`,
|
||||
`IAM_KEY_MINT_ALLOWED_APPS`, `IAM_ADMIN_MINT_ALLOWED_APPS` (matched by the
|
||||
globally-unique `client_id`).
|
||||
|
||||
The service is embeddable via `server.Route` and builds on Hanzo CI
|
||||
(`ghcr.io/hanzoai/iam`).
|
||||
|
||||
## Client auth (HIP-0111)
|
||||
|
||||
Authenticate **only** through `@hanzo/iam` against the canonical OIDC endpoints —
|
||||
no hand-rolled OAuth, no `genericOAuth({discoveryUrl})`, no per-app path strings,
|
||||
no legacy paths. SDK subpaths cover every runtime: `@hanzo/iam/server`
|
||||
(`validateToken` / `getServerSession`), `@hanzo/iam/betterauth`,
|
||||
`@hanzo/iam/nextauth`, `@hanzo/iam/react` + `@hanzo/iam/browser` (SPA PKCE),
|
||||
`@hanzo/iam/passport`. Keep `originFrontend` empty in prod so discovery is
|
||||
host-relative.
|
||||
|
||||
## Status
|
||||
|
||||
OIDC/OAuth2 core is live and tested end to end (login → PKCE code → token → JWT):
|
||||
discovery + JWKS, credential login (argon2id / bcrypt), the token endpoint,
|
||||
introspection / revocation, UserInfo, RFC 8693 token exchange, SCIM 2.0
|
||||
provisioning, and Google / GitHub federation. Current release line: `v1.33.x`.
|
||||
See [MIGRATION.md](./MIGRATION.md) for the phased plan and the full RFC surface
|
||||
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
|
||||
|
||||
Open source · every language · on-chain settlement. [hanzo.ai](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
|
||||
|
||||
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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
|
||||
// identity via the injected Store — so it shares ONE identity store with the core
|
||||
// and never carries a second copy. The core NEVER imports a module; dependency
|
||||
// flows one way (module → feature).
|
||||
//
|
||||
// What belongs OUT here is PROVENANCE, and only provenance: this tree is
|
||||
// clean-room (see TestCasdoorLineageRetracted), so a capability whose
|
||||
// implementation is Casdoor-derived stays in a hanzoiam/* module carrying its own
|
||||
// Apache-2.0 attribution — SAML's IdP protocol code and LDAP's directory server
|
||||
// both are. A capability written fresh belongs IN the core, where the Guard,
|
||||
// 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. 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 (
|
||||
"context"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// Store is the identity surface a feature needs — the union of the calls the
|
||||
// copied the legacy surface code makes (object.* → store.*). The core implements it over its
|
||||
// orm store (internal/featurestore). A feature ignores methods it doesn't use.
|
||||
type Store interface {
|
||||
GetUser(ctx context.Context, owner, name string) (*model.User, error)
|
||||
// GetUserByID resolves a user by model.User.Id — the stable opaque UUID the
|
||||
// OIDC `sub` carries, which AddUser mints server-side and UpdateUser carries
|
||||
// forward. It is the id a module hands back to a client as the user's stable
|
||||
// handle, so it must be THIS value and never the orm storage id, which differs
|
||||
// per row and is mutable for migrated rows. Returns (nil, nil) when no user
|
||||
// matches.
|
||||
GetUserByID(ctx context.Context, id string) (*model.User, error)
|
||||
GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error)
|
||||
AddUser(ctx context.Context, u *model.User) (bool, error)
|
||||
UpdateUser(ctx context.Context, u *model.User) (bool, error)
|
||||
DeleteUser(ctx context.Context, owner, name string) (bool, error)
|
||||
GetApplication(ctx context.Context, id string) (*model.Application, error)
|
||||
GetOrganization(ctx context.Context, name string) (*model.Organization, error)
|
||||
// GetProvider resolves an identity provider by (owner, name) — the SP-inbound
|
||||
// SAML/OAuth surface (a user signing in through a corporate IdP where Hanzo is
|
||||
// the Service Provider). SAML SP-initiated login reads its IdP config from here.
|
||||
GetProvider(ctx context.Context, owner, name string) (*model.Provider, error)
|
||||
// GetCert resolves a signing cert by (owner, name) — SAML metadata signing, etc.
|
||||
GetCert(ctx context.Context, owner, name string) (*model.Cert, error)
|
||||
// SetPassword sets a user's password: the core hashes the plaintext exactly
|
||||
// once and stores only the one-way digest (never the clear text). An empty
|
||||
// plaintext leaves the digest untouched. Hashing lives in ONE place (the core) —
|
||||
// a module never sees a hash, and never grows its own.
|
||||
SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
// VerifyPassword reports whether plaintext matches the user's stored digest
|
||||
// (argon2id for migrated v1 rows, bcrypt for v2, per the org's password type).
|
||||
// Used by LDAP bind — verification stays in the core, never in a module.
|
||||
VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
}
|
||||
|
||||
// Feature is one pluggable enterprise capability. Route activates it against the
|
||||
// shared app, backed by store. Name is for diagnostics.
|
||||
//
|
||||
// Route is really "activate", and the name is honest for only one of the two
|
||||
// modules: hanzoiam/saml registers HTTP routes on app, while hanzoiam/ldap takes
|
||||
// app as `_` and binds its own TCP listeners — same hook, no routes. Rename it to
|
||||
// Start when this seam first grows a composing binary; it has none today, so the
|
||||
// rename is free then and a coordinated three-repo break now.
|
||||
type Feature interface {
|
||||
Name() string
|
||||
Route(app *zip.App, store Store) error
|
||||
}
|
||||
|
||||
var registry []Feature
|
||||
|
||||
// Register adds a feature to the set RouteAll registers. Called by the composing
|
||||
// binary (cloud) or a module init — the core decides which enterprise features ship.
|
||||
func Register(f Feature) { registry = append(registry, f) }
|
||||
|
||||
// Registered returns the registered features (diagnostics/tests).
|
||||
func Registered() []Feature { return append([]Feature(nil), registry...) }
|
||||
|
||||
// RouteAll registers every registered feature on app with store, fail-fast: a
|
||||
// registered-but-broken enterprise module surfaces loudly at boot, never a silent no-op.
|
||||
func RouteAll(app *zip.App, store Store) error {
|
||||
for _, f := range registry {
|
||||
if err := f.Route(app, store); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package feature_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/feature"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// A registered feature is routed by RouteAll and reaches the app + store; a
|
||||
// module that fails to register surfaces the error (fail-fast).
|
||||
type fakeFeature struct {
|
||||
name string
|
||||
registered bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFeature) Name() string { return f.name }
|
||||
func (f *fakeFeature) Route(app *zip.App, store feature.Store) error {
|
||||
f.registered = true
|
||||
return f.err
|
||||
}
|
||||
|
||||
type nopStore struct{}
|
||||
|
||||
func (nopStore) GetUser(context.Context, string, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetUserByID(context.Context, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetGlobalUsers(context.Context, int, int) ([]*model.User, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (nopStore) AddUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) UpdateUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) DeleteUser(context.Context, string, string) (bool, error) { return true, nil }
|
||||
func (nopStore) GetApplication(context.Context, string) (*model.Application, error) { return nil, nil }
|
||||
func (nopStore) GetOrganization(context.Context, string) (*model.Organization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) GetCert(context.Context, string, string) (*model.Cert, error) { return nil, nil }
|
||||
func (nopStore) GetProvider(context.Context, string, string) (*model.Provider, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) SetPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (nopStore) VerifyPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestRouteAll_RegistersEveryFeature(t *testing.T) {
|
||||
f := &fakeFeature{name: "fake"}
|
||||
feature.Register(f)
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := feature.RouteAll(app, nopStore{}); err != nil {
|
||||
t.Fatalf("RouteAll: %v", err)
|
||||
}
|
||||
if !f.registered {
|
||||
t.Fatal("registered feature never had Route called")
|
||||
}
|
||||
found := false
|
||||
for _, r := range feature.Registered() {
|
||||
if r.Name() == "fake" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Registered() did not list the feature")
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,115 @@
|
||||
module github.com/hanzoai/iam2
|
||||
module github.com/hanzoai/iam
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
// Hanzo IAM v2 stack (MIGRATION.md §2) — no base, no consensus engine:
|
||||
// This path carries two histories. Everything below v1.32.0 published the
|
||||
// Casdoor-derived tree (Beego/xorm, controllers/); v1.32.0 and above publish
|
||||
// this one (zip/orm, internal/). Same import path, no signal — which made
|
||||
// `go get github.com/hanzoai/iam@v1.31.28` a lineage swap that still compiles.
|
||||
//
|
||||
// Those versions now live, byte-identical, at github.com/hanzoai/iam-v1.
|
||||
// Deleting their tags here would not un-publish them: proxy.golang.org caches
|
||||
// module versions immutably and already serves 506 of them. This retraction is
|
||||
// therefore the only thing that reaches every resolver, proxied or direct.
|
||||
retract [v1.0.0, v1.31.37] // Casdoor lineage; moved to github.com/hanzoai/iam-v1
|
||||
|
||||
// Hanzo IAM stack (MIGRATION.md §2) — no base, no consensus engine:
|
||||
// - github.com/zap-proto/zip — typed HTTP handlers on the zap-proto/fiber v3 engine
|
||||
// - github.com/hanzoai/orm — typed Go records over SQLite / hanzoai/sql / hanzoai/datastore
|
||||
require (
|
||||
github.com/hanzoai/orm v0.6.1
|
||||
github.com/hanzoai/orm v0.6.16
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zap-proto/zip v1.6.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 `iam2 compare`
|
||||
// can read the v1 Casdoor Postgres/MySQL database. The default (serving) build
|
||||
// Migration-only: linked solely in `go build -tags migration` so `iam compare`
|
||||
// can read the legacy v1 Postgres/MySQL database. The default (serving) build
|
||||
// never links these — it is SQLite/ZAP-only, no external SQL driver.
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
)
|
||||
|
||||
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.1-0.20241114170450-2d3c2a9cc518
|
||||
github.com/hanzoai/account v0.2.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
|
||||
github.com/zap-proto/fiber/v3 v3.2.1
|
||||
)
|
||||
|
||||
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
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanw/esbuild v0.28.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hanzoai/dbx v1.16.0 // indirect
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0 // indirect
|
||||
github.com/hanzoai/sqlite v0.2.1 // 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.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
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/luxfi/accel v1.2.4 // 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.5.1 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // 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-sqlite3 v1.14.47 // 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.20220721030215-126854af5e6d // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.70.0 // indirect
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
|
||||
github.com/zap-proto/go v1.3.0 // indirect
|
||||
github.com/zap-proto/http v0.2.0 // indirect
|
||||
github.com/zap-proto/http v0.3.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.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.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.0 // 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.48.1 // indirect
|
||||
)
|
||||
|
||||
// Local checkouts during the migration so iam2 stays in sync with patches
|
||||
// landing in orm and zip. Switch to pinned vX.Y.Z once the v2 surface
|
||||
// stabilises (Phase 1).
|
||||
replace (
|
||||
github.com/hanzoai/orm => ../orm
|
||||
github.com/zap-proto/zip => ../../zap-proto/zip
|
||||
modernc.org/sqlite v1.51.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,53 +1,107 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
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=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
|
||||
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
|
||||
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
|
||||
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/hanzoai/dbx v1.16.0 h1:C8wsb9BIiit4nYnXizpcB4SyzVaepPkQFwq5i9fxAV0=
|
||||
github.com/hanzoai/dbx v1.16.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0 h1:vO2SD8dV0+H9WWCVKV9KHaWZq4yeMsZruohrsZN9448=
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0/go.mod h1:S+Li20E6Bskpw6r+c8WWhfi4hCr8SVV32qPXO0wdl+E=
|
||||
github.com/hanzoai/sqlite v0.2.1 h1:PqUty8+NhJsfwzT5K/U6vgFSIykM1vM0GMLeoH2KWio=
|
||||
github.com/hanzoai/sqlite v0.2.1/go.mod h1:SVhzKrbEovivr/sEaL/Wgw81a7Xfy6gSoOMzuRCvt7s=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/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=
|
||||
github.com/hanzoai/builder v0.3.13/go.mod h1:TWZaiP0Y9tCMwtLH2EvQqBAeT1f3aJI5Y0XPM8S0wcE=
|
||||
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
|
||||
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
|
||||
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.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=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -58,24 +112,76 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
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.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.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/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-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 h1:yfQ2sO9WJXUAIUR+g7NUkxJSKCAFJcR5sUDu+ZmjTZI=
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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=
|
||||
@@ -87,55 +193,160 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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.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=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
|
||||
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.2.0 h1:WiTqJ7Wh0O2qA3DNhvyi0b9F4j2wX8ctZDlW46WMxWQ=
|
||||
github.com/zap-proto/http v0.2.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
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.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=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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.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=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
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=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.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=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.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=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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.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=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
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/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
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=
|
||||
@@ -144,18 +355,18 @@ 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.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
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.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,304 @@
|
||||
// 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
|
||||
// Out) over hanzoai/orm and is owner-scoped by the (owner, name) natural key,
|
||||
// materialized as the orm id "<owner>/<name>". The same In/Out types back both
|
||||
// the REST route and the MCP tools/call projection zip derives from them, so
|
||||
// identity arguments travel in the typed request, not in ad-hoc path parsing.
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// authorizeOrganization gates the Organization an application will SERVE (the
|
||||
// tenant a credential minted through it lands in), not just its registry Owner:
|
||||
// the op-invoke authz hook authorizes the top-level Owner, but Organization is a
|
||||
// separate field that a tenant admin could otherwise set to the reserved admin
|
||||
// org (a SuperAdmin-minting app) or to a victim tenant. On a gated HTTP request
|
||||
// the Guard attached a Principal; a non-super may point an app only at its OWN
|
||||
// org. A server-internal call (bootstrap/seed) carries no Principal and is
|
||||
// trusted, so an unauthenticated context is left to the surrounding trust
|
||||
// boundary rather than blocked here.
|
||||
func authorizeOrganization(ctx context.Context, in *schema.Application) error {
|
||||
if in.Organization == "" {
|
||||
return nil // an org-less app mints no cross-tenant/SuperAdmin identity
|
||||
}
|
||||
p, ok := authz.From(ctx)
|
||||
if !ok {
|
||||
return nil // server-internal (no principal) — trusted caller
|
||||
}
|
||||
if !authz.CanSetOrg(p, in.Organization) {
|
||||
return zip.ErrForbidden("not authorized to set the application organization to " + in.Organization)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClientIdUnique rejects a create/update whose clientId is already held by a
|
||||
// DIFFERENT application (any owner). clientId is the GLOBAL key the mint and Basic-auth
|
||||
// resolvers authenticate against, so it must be unique across every owner, not merely
|
||||
// within one — otherwise a tenant could register a row whose clientId collides with a
|
||||
// platform console's and (on a backend whose duplicate-row order is unspecified) shadow
|
||||
// it. A JSON-document store has no per-field column to carry a DB UNIQUE index, so the
|
||||
// invariant is enforced here at the write, exactly as the (owner,name) natural key is.
|
||||
// An empty clientId cannot collide (a public app authenticates no confidential grant);
|
||||
// the self-row (same owner,name) is skipped so an update that keeps its own clientId is
|
||||
// never a self-collision.
|
||||
func ensureClientIdUnique(ctx context.Context, db orm.DB, clientId, owner, name string) error {
|
||||
if clientId == "" {
|
||||
return nil
|
||||
}
|
||||
existing, err := store.ListApplicationsByClientId(ctx, db, clientId)
|
||||
if err != nil {
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
for _, a := range existing {
|
||||
if a.Owner != owner || a.Name != name {
|
||||
return zip.ErrConflict("clientId already in use: " + clientId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appID is the owner-scoped natural key "<owner>/<name>" — the single source
|
||||
// of an application's orm id. Every handler routes through it so reads and
|
||||
// writes address the exact same row.
|
||||
func appID(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// ApplicationRef identifies one application by its owner-scoped natural key.
|
||||
// It is the input for the get and delete operations.
|
||||
type ApplicationRef struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationQuery filters applications by owner for the list operation.
|
||||
type ApplicationQuery struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationListResult wraps the applications owned by one owner, newest
|
||||
// first.
|
||||
type ApplicationListResult struct {
|
||||
Applications []*schema.Application `json:"applications"`
|
||||
}
|
||||
|
||||
// DeleteResult reports the outcome of a delete operation.
|
||||
type DeleteResult struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the applications CRUD surface on app, closing over db.
|
||||
//
|
||||
// The kind is addressed in the PLURAL, like every other kind in this service —
|
||||
// users, certs, roles, invitations, keys, projects, workspaces, permissions,
|
||||
// providers, tokens, sessions, organizations, audit-logs,
|
||||
// webauthn-credentials — with `/get`, `/update` and `/delete` under it. This was
|
||||
// the only singular, so `/v1/iam/application` and `/v1/iam/applications` both
|
||||
// answered and which spelling a reader wanted depended on the operation.
|
||||
// Fourteen kinds against one is not a matter of taste; the odd one moved.
|
||||
//
|
||||
// The singular address stays reachable on the SAME typed handlers, tagged
|
||||
// `compat` — which is what keeps it out of the published document and therefore
|
||||
// out of every SDK, docs page and CLI command. It is deleted when the last
|
||||
// pinned consumer moves.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
zip.Get(app, "/v1/iam/applications", listApplications(db), zip.WithTags("applications"))
|
||||
zip.Post(app, "/v1/iam/applications", Create(db), zip.WithTags("applications"))
|
||||
zip.Get(app, "/v1/iam/applications/get", getApplication(db), zip.WithTags("applications"))
|
||||
zip.Post(app, "/v1/iam/applications/update", Update(db), zip.WithTags("applications"))
|
||||
zip.Post(app, "/v1/iam/applications/delete", deleteApplication(db), zip.WithTags("applications"))
|
||||
|
||||
zip.Get(app, "/v1/iam/application", getApplication(db), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/application", Create(db), zip.WithTags("compat"))
|
||||
zip.Put(app, "/v1/iam/application", Update(db), zip.WithTags("compat"))
|
||||
zip.Delete(app, "/v1/iam/application", deleteApplication(db), zip.WithTags("compat"))
|
||||
}
|
||||
|
||||
// listApplications returns the applications in one organization, newest first —
|
||||
// each product or site your people sign in to, with the sign-in methods and
|
||||
// redirect URIs it allows.
|
||||
func listApplications(db orm.DB) zip.TypedHandler[ApplicationQuery, ApplicationListResult] {
|
||||
return func(ctx context.Context, in *ApplicationQuery) (*ApplicationListResult, error) {
|
||||
if in.Owner == "" {
|
||||
return nil, zip.ErrBadRequest("owner is required")
|
||||
}
|
||||
apps, err := orm.TypedQuery[schema.Application](db).
|
||||
Filter("Owner=", in.Owner).
|
||||
Order("-CreatedTime").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
for i, app := range apps {
|
||||
apps[i] = app.Mask() // never emit clientSecret in a list response
|
||||
}
|
||||
return &ApplicationListResult{Applications: apps}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// getApplication returns one application: its sign-in methods, its allowed
|
||||
// redirect URIs and the client credentials your integration authenticates with.
|
||||
func getApplication(db orm.DB) zip.TypedHandler[ApplicationRef, schema.Application] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return app.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create registers an application in your organization — one product or site
|
||||
// your people sign in to, with its own client credentials, sign-in methods and
|
||||
// allowed redirect URIs. A name already used in the organization is refused
|
||||
// rather than overwritten.
|
||||
//
|
||||
// Exported so the legacy add-application alias reuses this exact path — one
|
||||
// create, two spellings.
|
||||
func Create(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := authorizeOrganization(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
// Owner-scoped uniqueness: (owner, name) must be free.
|
||||
if _, err := orm.Get[schema.Application](db, id); err == nil {
|
||||
return nil, zip.ErrConflict("application already exists: " + id)
|
||||
} else if !errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// Global uniqueness: clientId is the mint/Basic-auth resolution key, so it must
|
||||
// be free across ALL owners — the invariant the confidential-client gates rely on.
|
||||
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bind the decoded entity to db under its natural key and persist.
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
if err := in.Create(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update changes an application's display, its sign-in methods and the redirect
|
||||
// URIs it may return to — the call that makes login work from a new host. Which
|
||||
// organization it belongs to and what it is named are fixed when it is created
|
||||
// and are not editable here.
|
||||
//
|
||||
// Exported so the legacy update-application alias reuses this exact path — one
|
||||
// update, two spellings.
|
||||
func Update(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := authorizeOrganization(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
existing, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// Global clientId uniqueness (see Create): an update may keep its own clientId
|
||||
// but must never steal another app's.
|
||||
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A write that says NOTHING about the credential must not destroy it.
|
||||
//
|
||||
// This verb is a full REPLACE, and every read of an application MASKS its
|
||||
// client secret (Mask, and get-app-login before it) — so the natural admin
|
||||
// round-trip, read the record, change one field, write it back, silently
|
||||
// posted ClientSecret:"" and de-secreted the app. Measured on live IAM: the
|
||||
// SuperAdmin read of hanzo-console, hanzo-app, hanzo-id and hanzo-cloud all
|
||||
// return "" while a token-endpoint probe proves all four DO hold a secret.
|
||||
// Any console "save" on an application page was one request away from turning
|
||||
// a confidential client public — which the token endpoint then reads as "PKCE,
|
||||
// demand no client auth", weakening every flow that app serves.
|
||||
//
|
||||
// So an OMITTED secret preserves what is stored. This is the same rule the
|
||||
// operator upsert already settled in resolveSecret ("existing app -> preserve
|
||||
// what it has"), stated once more here because this is the other door onto the
|
||||
// same row; rotation stays possible, it just has to be DELIBERATE — send the
|
||||
// new secret to change it.
|
||||
//
|
||||
// Clearing a secret on purpose (confidential -> public) is therefore no longer
|
||||
// expressible as an accident. It goes through the operator upsert's explicit
|
||||
// `public: true`, which is the one place that decision is named.
|
||||
if in.ClientSecret == "" {
|
||||
in.ClientSecret = existing.ClientSecret
|
||||
}
|
||||
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
in.CreatedTime = existing.CreatedTime
|
||||
in.CreatedAt = existing.CreatedAt
|
||||
if err := in.Update(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete exposes the same handler to the legacy delete-application alias — one
|
||||
// delete path, wrapped in that surface's envelope.
|
||||
func Delete(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] { return deleteApplication(db) }
|
||||
|
||||
// deleteApplication removes an application. Anyone mid-sign-in through it is
|
||||
// turned away and its client credentials stop working, so retire the integration
|
||||
// before deleting it.
|
||||
func deleteApplication(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*DeleteResult, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := app.Delete(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteResult{Deleted: true}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
func memDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "apptest.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// The clientId global-uniqueness guard on create: a create may not take a clientId
|
||||
// already held by a DIFFERENT (owner,name), so a tenant can never register a row that
|
||||
// collides with a platform console's confidential-client key. This is the store-layer
|
||||
// enforcement of the invariant the mint/Basic-auth gates rely on (a JSON-document
|
||||
// store has no column for a DB UNIQUE index). A background ctx carries no principal,
|
||||
// so authorizeOrganization is the trusted server-internal path and the guard is
|
||||
// exercised in isolation.
|
||||
func TestCreate_RejectsDuplicateClientId(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
create := Create(db)
|
||||
|
||||
// The legit platform console.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
|
||||
t.Fatalf("seed console: %v", err)
|
||||
}
|
||||
|
||||
// A tenant tries to register a DIFFERENT (owner,name) with the SAME clientId.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "evil", Name: "evil-console", ClientId: "hanzo-console"}); err == nil {
|
||||
t.Fatal("HIGH REOPENED: a colliding clientId was accepted on create")
|
||||
}
|
||||
|
||||
// A distinct clientId under a tenant is fine (no false positive).
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
|
||||
t.Fatalf("a distinct clientId must be accepted: %v", err)
|
||||
}
|
||||
|
||||
// A public app (no clientId) never collides with another public app.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-a"}); err != nil {
|
||||
t.Fatalf("empty clientId #1: %v", err)
|
||||
}
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-b"}); err != nil {
|
||||
t.Fatalf("empty clientId #2 must not collide with #1: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update may keep its OWN clientId (the self-row is skipped, never a self-collision)
|
||||
// but must not steal another app's.
|
||||
func TestUpdate_ClientIdCollision(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
create, update := Create(db), Update(db)
|
||||
|
||||
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
|
||||
t.Fatalf("seed console: %v", err)
|
||||
}
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
|
||||
t.Fatalf("seed tenant app: %v", err)
|
||||
}
|
||||
|
||||
// hanzo-app keeps its own clientId on update — allowed (self-row skipped).
|
||||
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app", DisplayName: "renamed"}); err != nil {
|
||||
t.Fatalf("keeping own clientId on update must be allowed: %v", err)
|
||||
}
|
||||
|
||||
// hanzo-app tries to STEAL the console's clientId — rejected.
|
||||
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-console"}); err == nil {
|
||||
t.Fatal("HIGH REOPENED: an update stole another app's clientId")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// THE ADMIN ROUND-TRIP MUST NOT DE-SECRET AN APP.
|
||||
//
|
||||
// update-application is a full REPLACE and every read MASKS the client secret, so
|
||||
// "read the record, change one field, write it back" — the only shape an admin UI
|
||||
// or an operator has — posted ClientSecret:"" and silently turned a confidential
|
||||
// client public. Measured on live IAM: the SuperAdmin read of hanzo-console,
|
||||
// hanzo-app, hanzo-id and hanzo-cloud all return "" while a token-endpoint probe
|
||||
// proves all four hold a secret. The token endpoint reads a stored empty secret as
|
||||
// "public client, demand no client auth", so the blast radius is every flow those
|
||||
// apps serve.
|
||||
|
||||
func seedConfidential(t *testing.T, db orm.DB, name, secret string) *schema.Application {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "admin", name
|
||||
a.ClientId, a.ClientSecret = name, secret
|
||||
a.Organization = "hanzo"
|
||||
a.SetId("admin/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// The regression: a write echoing a MASKED read preserves the credential.
|
||||
func TestUpdate_MaskedRoundTripPreservesTheSecret(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
seedConfidential(t, db, "hanzo-console", "s3cret-do-not-lose-me")
|
||||
|
||||
// Exactly what an admin round-trip carries: the record as READ (secret masked
|
||||
// to ""), with one unrelated field changed.
|
||||
echoed := &schema.Application{
|
||||
Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console",
|
||||
Organization: "hanzo", ClientSecret: "", EnableSignUp: false,
|
||||
}
|
||||
if _, err := Update(db)(ctx, echoed); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
got, err := orm.Get[schema.Application](db, "admin/hanzo-console")
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.ClientSecret != "s3cret-do-not-lose-me" {
|
||||
t.Fatalf("the admin round-trip DE-SECRETED the app: ClientSecret = %q, want it preserved.\n"+
|
||||
"An empty secret is what the token endpoint reads as 'public client, no client auth'.",
|
||||
got.ClientSecret)
|
||||
}
|
||||
if got.EnableSignUp {
|
||||
t.Errorf("the field the caller actually meant to change did not land")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation stays possible — it just has to be deliberate.
|
||||
func TestUpdate_ExplicitSecretStillRotates(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
seedConfidential(t, db, "rotate-me", "old-secret")
|
||||
|
||||
in := &schema.Application{
|
||||
Owner: "admin", Name: "rotate-me", ClientId: "rotate-me",
|
||||
Organization: "hanzo", ClientSecret: "brand-new-secret",
|
||||
}
|
||||
if _, err := Update(db)(ctx, in); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := orm.Get[schema.Application](db, "admin/rotate-me")
|
||||
if got.ClientSecret != "brand-new-secret" {
|
||||
t.Fatalf("deliberate rotation was swallowed: %q", got.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// An app that genuinely has no secret stays that way — preserving "" is not the
|
||||
// same as minting one.
|
||||
func TestUpdate_PublicClientStaysPublic(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
seedConfidential(t, db, "public-spa", "")
|
||||
|
||||
in := &schema.Application{
|
||||
Owner: "admin", Name: "public-spa", ClientId: "public-spa",
|
||||
Organization: "hanzo", ClientSecret: "",
|
||||
}
|
||||
if _, err := Update(db)(ctx, in); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := orm.Get[schema.Application](db, "admin/public-spa")
|
||||
if got.ClientSecret != "" {
|
||||
t.Fatalf("a public client was handed a secret it never had: %q", got.ClientSecret)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package applications
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("DELETE /v1/iam/application", zip.Doc{
|
||||
Description: "Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/application", zip.Doc{
|
||||
Description: "Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/iam/applications", zip.Doc{
|
||||
Description: "Returns the applications in one organization, newest first —\neach product or site your people sign in to, with the sign-in methods and\nredirect URIs it allows.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/iam/applications/get", zip.Doc{
|
||||
Description: "Returns one application: its sign-in methods, its allowed\nredirect URIs and the client credentials your integration authenticates with.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/application", zip.Doc{
|
||||
Description: "Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/applications", zip.Doc{
|
||||
Description: "Registers an application in your organization — one product or site\nyour people sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs. A name already used in the organization is refused\nrather than overwritten.\n\nExported so the legacy add-application alias reuses this exact path — one\ncreate, two spellings.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/applications/delete", zip.Doc{
|
||||
Description: "Removes an application. Anyone mid-sign-in through it is\nturned away and its client credentials stop working, so retire the integration\nbefore deleting it.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/applications/update", zip.Doc{
|
||||
Description: "Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/iam/application", zip.Doc{
|
||||
Description: "Changes an application's display, its sign-in methods and the redirect\nURIs it may return to — the call that makes login work from a new host. Which\norganization it belongs to and what it is named are fixed when it is created\nand are not editable here.\n\nExported so the legacy update-application alias reuses this exact path — one\nupdate, two spellings.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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
|
||||
// is a typed zip handler over hanzoai/orm; the orm string key is "owner/name".
|
||||
// Reads scope to one owner (organization); writes address one log by its
|
||||
// (owner, name) key. Rows are written once at request time — the update path
|
||||
// exists only for administrative correction, never for normal operation.
|
||||
package auditlogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// Handler binds the audit-log operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the audit-log CRUD routes on app against db.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/audit-logs", h.List, zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs", h.Create, zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/get", h.Get, zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/update", h.Update, zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/delete", h.Delete, zip.WithTags("audit-logs"))
|
||||
}
|
||||
|
||||
// Ref addresses one audit log by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Input is the writable projection of an audit log (the v1 add/update-record
|
||||
// body). It keeps the HTTP contract clean of the orm.Model bookkeeping fields
|
||||
// and of the v1 integer surrogate id, which the orm string key supersedes.
|
||||
type Input struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
Organization string `json:"organization"`
|
||||
ClientIp string `json:"clientIp"`
|
||||
User string `json:"user"`
|
||||
Method string `json:"method"`
|
||||
RequestUri string `json:"requestUri"`
|
||||
Action string `json:"action"`
|
||||
Language string `json:"language"`
|
||||
Object string `json:"object"`
|
||||
Response string `json:"response"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
IsTriggered bool `json:"isTriggered"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of audit logs, newest first.
|
||||
type ListOutput struct {
|
||||
AuditLogs []*schema.AuditLog `json:"auditLogs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// apply copies the mutable domain fields of an Input onto an audit log. The
|
||||
// identity fields (owner, name) and the created stamp are set only on Create,
|
||||
// never overwritten by an update.
|
||||
func apply(dst *schema.AuditLog, in *Input) {
|
||||
dst.Organization = in.Organization
|
||||
dst.ClientIp = in.ClientIp
|
||||
dst.User = in.User
|
||||
dst.Method = in.Method
|
||||
dst.RequestUri = in.RequestUri
|
||||
dst.Action = in.Action
|
||||
dst.Language = in.Language
|
||||
dst.Object = in.Object
|
||||
dst.Response = in.Response
|
||||
dst.StatusCode = in.StatusCode
|
||||
dst.IsTriggered = in.IsTriggered
|
||||
}
|
||||
|
||||
// List returns your organization's audit trail, newest first — who did
|
||||
// what, when, and from where. It is the record you reach for during a security
|
||||
// review or an incident.
|
||||
//
|
||||
// You see your own organization's audit trail and no one else's; which organization that
|
||||
// is comes from your credentials, not from the request.
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
// The owner is resolved by authz.Scope from the authenticated principal,
|
||||
// never taken from the input: a tenant reads only its own org, a SuperAdmin
|
||||
// reads the owner it asks for. Filtering on in.Owner instead was a confused
|
||||
// deputy — the Guard authorizes on the query string, then a typed GET binds
|
||||
// NOTHING from it (zip typed.go reads a body only for non-GET), so in.Owner
|
||||
// arrived empty on every REST call and the "empty owner lists everything"
|
||||
// branch returned every tenant.
|
||||
owner, err := authz.Scope(ctx, in.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.AuditLog](h.db)
|
||||
if owner != "" {
|
||||
q = q.Filter("owner", owner)
|
||||
}
|
||||
logs, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &ListOutput{AuditLogs: logs, Total: len(logs)}, nil
|
||||
}
|
||||
|
||||
// Get returns one audit entry in full: the action, the person or key behind it,
|
||||
// and the request it came in on.
|
||||
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.AuditLog, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Create records an audit entry, so activity from your own systems lands in the
|
||||
// same trail as everything the Hanzo Cloud records for you.
|
||||
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, error) {
|
||||
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")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
log := orm.New[schema.AuditLog](h.db)
|
||||
log.Owner = in.Owner
|
||||
log.Name = in.Name
|
||||
log.CreatedTime = in.CreatedTime
|
||||
if log.CreatedTime == "" {
|
||||
log.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
apply(log, in)
|
||||
log.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := log.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Update corrects an audit entry. The trail is append-only in normal operation
|
||||
// and nothing in the Hanzo Cloud rewrites it — this exists for an administrator
|
||||
// to correct an entry their own systems recorded wrongly.
|
||||
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
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())
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Delete removes an audit entry. Retention policy is normally what should expire
|
||||
// a trail; deleting by hand leaves a gap a reviewer will notice.
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
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) {
|
||||
return zip.ErrNotFound("audit log not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package auditlogs
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/audit-logs", zip.Doc{
|
||||
Description: "Returns your organization's audit trail, newest first — who did\nwhat, when, and from where. It is the record you reach for during a security\nreview or an incident.\n\nYou see your own organization's audit trail and no one else's; which organization that\nis comes from your credentials, not from the request.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/audit-logs", zip.Doc{
|
||||
Description: "Records an audit entry, so activity from your own systems lands in the\nsame trail as everything the Hanzo Cloud records for you.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/audit-logs/delete", zip.Doc{
|
||||
Description: "Removes an audit entry. Retention policy is normally what should expire\na trail; deleting by hand leaves a gap a reviewer will notice.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/audit-logs/get", zip.Doc{
|
||||
Description: "Returns one audit entry in full: the action, the person or key behind it,\nand the request it came in on.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/audit-logs/update", zip.Doc{
|
||||
Description: "Corrects an audit entry. The trail is append-only in normal operation\nand nothing in the Hanzo Cloud rewrites it — this exists for an administrator\nto correct an entry their own systems recorded wrongly.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.AuditLog].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
// 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
|
||||
// through to overwrite an admin-owned signing cert and forge tokens. It is two
|
||||
// orthogonal decisions, never braided:
|
||||
//
|
||||
// - AUTHENTICATION — the Guard middleware, registered ONCE via app.Use, AFTER the
|
||||
// public group and BEFORE the authed routes. Public (pre-authentication)
|
||||
// routes are registered first, so a matched one terminates fiber's middleware
|
||||
// walk and the Guard never runs on it — public vs gated is structural (which
|
||||
// group a route is on), not an allow-list. Every request the Guard wraps must
|
||||
// carry a verified bearer; the resolved Principal is attached to the request
|
||||
// context for the authorization decision and audit. Fails closed (401).
|
||||
//
|
||||
// - AUTHORIZATION — the Authorize hook, installed ONCE via app.Authorize. It
|
||||
// runs at the framework's op-invoke seam, on the DECODED typed input the
|
||||
// handler will act on, for REST and MCP alike. The value it authorizes is by
|
||||
// construction the value the handler binds: there is no second parse of the
|
||||
// body for it to diverge from. Fails closed (403).
|
||||
//
|
||||
// Splitting the two removes the defect a single body-reparsing middleware had:
|
||||
// authorizing a target extracted from the raw bytes divergently from where the
|
||||
// handler binds it. A write's target now comes from the one decode the handler
|
||||
// itself runs on. A read's target rides in the query string (a GET has no body
|
||||
// for the op seam to decode), so the Guard authorizes reads there; a read invoked
|
||||
// over MCP DOES decode a target into its input, and the op seam authorizes that.
|
||||
//
|
||||
// Three scopes, never conflated (conflation is privilege escalation):
|
||||
//
|
||||
// - SuperAdmin — the principal's organization is the reserved "admin" org.
|
||||
// The ONLY cross-tenant scope. Required for every write to a platform-owned
|
||||
// (admin/built-in) resource: the signing-cert poisoning gate, admin-scoped
|
||||
// application/provider registration, every reserved surface.
|
||||
// - Org admin — IsAdmin, scoped to its OWN organization. Manages every
|
||||
// resource its org owns; never another org's, never a platform-owned one.
|
||||
// - Regular user — self-service only: reading its own user record.
|
||||
//
|
||||
// One predicate governs SuperAdmin everywhere: the principal's organization is
|
||||
// "admin". That organization comes from the token SUBJECT — the authenticated
|
||||
// principal's own owner/name — never from the token's `owner`/`organization`
|
||||
// claims. Those name the APPLICATION's org and diverge from the user's org for a
|
||||
// shared app, so trusting them would let a tenant user sign in through a shared
|
||||
// admin-org app and read as SuperAdmin. Authenticity, expiry, algorithm, and
|
||||
// signing-key trust are delegated to the same oidc.VerifyToken every protected
|
||||
// route already uses; the org-admin flag comes from the loaded user record, the
|
||||
// authoritative source (it is not a token claim).
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
|
||||
// cross-tenant scope, the one predicate. The broader reserved-owner set
|
||||
// {admin, built-in} the poisoning gate protects lives in ONE place,
|
||||
// store.IsSigningCertOwner, shared with the token verifier and the JWKS.
|
||||
const adminOrg = "admin"
|
||||
|
||||
// Principal is the identity a gated request acts as, resolved from a verified
|
||||
// bearer. Org is the tenant (the authenticated principal's own org, from the
|
||||
// subject); User is its name within that org (empty for a machine token); Admin
|
||||
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
|
||||
type Principal struct {
|
||||
Org string
|
||||
User string
|
||||
// App is the application NAME when the request authenticated as a confidential
|
||||
// client (client_secret_basic), and "" for every human. An app principal is
|
||||
// never Admin and never Super — its whole authority is its capability allowlist
|
||||
// (cap.go), so a leaked client credential can neither read another tenant nor
|
||||
// touch signing material.
|
||||
App string
|
||||
// AppOwner is the OWNING organization of that application row — "admin"/"built-in"
|
||||
// for a platform app, the tenant's own org for a customer app. It is NOT App's
|
||||
// served Organization. A capability (cap.go Allowed) is granted ONLY when this is
|
||||
// a reserved platform signing owner, so a tenant that registers an app whose NAME
|
||||
// (or clientId) collides with a platform console inherits none of its authority:
|
||||
// the allowlist keys on the name, and the owner-pin binds that name to the
|
||||
// platform. Empty for every human.
|
||||
AppOwner string
|
||||
// AppCert is the NAME of the signing cert that application row references
|
||||
// (schema.Application.Cert). It is carried on the principal so the self-read
|
||||
// clause can permit an app exactly one cert — its own — without the pure
|
||||
// authorize() decision having to reach into the store. Empty for every human.
|
||||
AppCert string
|
||||
Admin bool
|
||||
Super bool
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// From returns the Principal the Guard attached to ctx for a gated request, and
|
||||
// whether one is present (public routes carry none).
|
||||
func From(ctx context.Context) (*Principal, bool) {
|
||||
p, ok := ctx.Value(ctxKey{}).(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Scope resolves the owner an org-scoped request is bound to. It is the ONE
|
||||
// place the rule lives, and the rule is:
|
||||
//
|
||||
// AN ORG-SCOPED REQUEST IS HONOURED OR REFUSED, NEVER SILENTLY REINTERPRETED.
|
||||
//
|
||||
// A SuperAdmin — the only cross-tenant scope — is bound to the owner it names
|
||||
// (empty = every tenant). Everyone else is bound to its OWN org and may say so:
|
||||
// naming its own org, or naming none, both resolve to it. Naming a DIFFERENT org
|
||||
// is refused, because the one thing this function must never do is answer a
|
||||
// request about org B with org A's rows.
|
||||
//
|
||||
// It used to return p.Org for ANY owner, silently discarding the parameter.
|
||||
// Measured against production 2026-07-28 with the hanzo-console credential (home
|
||||
// org hanzo): ?owner=lux, ?owner=zoo and ?owner=nonexistent-org-xyz each answered
|
||||
// 200/ok with 262 `hanzo` accounts. No tenant's rows escaped IAM — the pin held —
|
||||
// so it was not a confidentiality breach here; it was MISATTRIBUTION, which is
|
||||
// worse in one specific way. Nothing in the status code, the `status` field, the
|
||||
// message or the count said the filter had been dropped, so the caller believed
|
||||
// it held tenant B while holding tenant A. An operator asked for lux, was handed
|
||||
// 262 hanzo accounts, and was one filter-and-delete from purging the wrong
|
||||
// tenant. Downstream it WAS a leak: cloud's IAM edge (cloud/iam_edge.go) checks
|
||||
// ?owner= against the calling tenant and then forwards it under ONE confidential
|
||||
// client, so every tenant's team page asked for its own org and was served the
|
||||
// edge credential's org instead. A pin that lies composes into a breach; a
|
||||
// refusal cannot.
|
||||
//
|
||||
// The refusal is NOT an org-existence oracle, and by construction rather than by
|
||||
// care: the decision is taken from the verified principal alone and never touches
|
||||
// the store, so `lux` (a real tenant), `built-in` (reserved) and
|
||||
// `nonexistent-org-xyz` (a fabrication) are the same comparison and the same
|
||||
// bytes out. Its text names the CREDENTIAL's org, never the requested one. That
|
||||
// is the same collapse cloud's per-org KMS store makes for this class of leak —
|
||||
// every spelling the caller may not have routes to ONE existence-independent
|
||||
// answer. It differs only in WHICH answer: KMS has no org parameter to refuse (it
|
||||
// reads the org from the token), so absence is its only observable and it answers
|
||||
// 404; here the org is a stated request parameter, so there IS an authorization
|
||||
// decision to report, and reporting it is the entire point.
|
||||
//
|
||||
// An empty p.Org is refused too. A non-super with no org has no org scope, and
|
||||
// returning "" would resolve to "no filter" — every tenant's rows, which is the
|
||||
// exact branch TestListRoutesNeverLeakAnotherTenant exists to keep shut. Fail
|
||||
// closed.
|
||||
func Scope(ctx context.Context, owner string) (string, error) {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return "", zip.ErrForbidden("no principal")
|
||||
}
|
||||
if p.Super {
|
||||
return owner, nil
|
||||
}
|
||||
if p.Org == "" || (owner != "" && owner != p.Org) {
|
||||
return "", errForeignOrg(p)
|
||||
}
|
||||
return p.Org, nil
|
||||
}
|
||||
|
||||
// errForeignOrg is the refusal a foreign owner earns. It is built from the
|
||||
// PRINCIPAL's own org and never from the requested one, so every org the caller
|
||||
// may not have — real, reserved, or invented — produces the byte-identical
|
||||
// answer. Naming the caller's own org discloses nothing (its rows already carry
|
||||
// it) and is what turns a bare "forbidden" into a diagnosis: you are pinned here,
|
||||
// you asked for somewhere else.
|
||||
func errForeignOrg(p *Principal) error {
|
||||
if p.Org == "" {
|
||||
return zip.ErrForbidden("forbidden: this credential carries no organization scope")
|
||||
}
|
||||
return zip.ErrForbidden("forbidden: this credential is scoped to organization " + p.Org)
|
||||
}
|
||||
|
||||
// Deny renders a Scope/ScopeFor refusal in the envelope the caller's surface
|
||||
// speaks — the SAME shaping the Guard's own refusal uses, so one refusal looks
|
||||
// the same whether it was raised before the handler or inside it. A handler that
|
||||
// answered it with httpx.Err would send HTTP 200 carrying {"status":"error"},
|
||||
// which is how a refusal gets logged as a success.
|
||||
func Deny(c *zip.Ctx, err error) error { return refuse(c, http.StatusForbidden, err.Error()) }
|
||||
|
||||
// ScopeFor resolves the owner a compat READ should query — the same decision as
|
||||
// Scope, except that a self-read addresses its own owner verbatim.
|
||||
//
|
||||
// Scope pins a non-SuperAdmin to p.Org, which for an app principal is the tenant it
|
||||
// SERVES (hanzo), not the org that OWNS its row (admin). So a confidential client
|
||||
// authorized by the Guard to read admin/hanzo-cloud then had the query rewritten to
|
||||
// hanzo/hanzo-cloud and got "the entity does not exist" — authorized and still
|
||||
// unable to read itself, a 200 that is functionally the 403 it replaced.
|
||||
//
|
||||
// Rather than loosen Scope (whose binding IS the tenant gate on the handler-authorized
|
||||
// paths — SCIM, service-accounts, memberships), the ONE self-read clause is asked
|
||||
// again here, through the same authorize() it is defined in. There is no second copy
|
||||
// of the rule: if authorize would admit this exact read, the owner it admitted is the
|
||||
// owner we query; otherwise Scope decides, and Scope now REFUSES a foreign owner
|
||||
// rather than rewriting it. That is the honour-or-refuse rule reaching this path
|
||||
// too: a grant honours the org it names and answers with THAT org's row, correctly
|
||||
// attributed; everything else is refused. Neither branch can hand back a row the
|
||||
// request did not ask for.
|
||||
func ScopeFor(ctx context.Context, path, owner, name string) (string, error) {
|
||||
if p, ok := From(ctx); ok && owner != "" && authorize(p, "GET", entityOf(path), owner, name) {
|
||||
if p.Super || (p.App != "" && owner == p.AppOwner) {
|
||||
return owner, nil
|
||||
}
|
||||
}
|
||||
return Scope(ctx, owner)
|
||||
}
|
||||
|
||||
// Can reports whether the ctx principal may perform `method` on the entity's
|
||||
// (owner, name) — the SAME policy the op-invoke seam (Authorize) applies, exposed
|
||||
// for a RAW handler that does not pass through app.Authorize (e.g. SCIM, whose
|
||||
// writes call the CRUD directly). Owner-pinning via Scope alone is NOT sufficient
|
||||
// for a write: it enforces tenant isolation but not the admin/self clause, so a
|
||||
// raw handler MUST call this. Fails closed when no principal is present.
|
||||
func Can(ctx context.Context, method, entity, owner, name string) bool {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return authorize(p, method, entity, owner, name)
|
||||
}
|
||||
|
||||
// IsSuper reports whether the ctx principal is a SuperAdmin — used by a raw
|
||||
// handler to gate a privileged field (e.g. provision-don't-promote: only a super
|
||||
// may set isAdmin). Fails closed when no principal is present.
|
||||
func IsSuper(ctx context.Context) bool {
|
||||
p, ok := From(ctx)
|
||||
return ok && p.Super
|
||||
}
|
||||
|
||||
// CanSetOrg reports whether principal p may point a resource at organization
|
||||
// `org` — the tenant an application SERVES (the org every credential minted
|
||||
// through that app lands in), authorized EXACTLY as an owner target through the
|
||||
// one policy: a SuperAdmin may set any org; anyone else only their OWN org, never
|
||||
// a reserved platform org (admin/built-in — the SuperAdmin/signing vector) nor
|
||||
// another tenant (cross-tenant mint). It is the gate the application create/update
|
||||
// path applies to the Organization FIELD — closing the hole where authorizing only
|
||||
// the top-level Owner let a tenant admin register an app whose Organization named
|
||||
// the admin org (SuperAdmin) or a victim tenant. Fails closed on a nil principal.
|
||||
func CanSetOrg(p *Principal, org string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return authorize(p, "POST", "applications", org, "")
|
||||
}
|
||||
|
||||
// Optional resolves the Principal a PUBLIC route's caller happens to carry, or
|
||||
// nil when the request is anonymous or its bearer does not verify. The Guard
|
||||
// admits a public path WITHOUT resolving a principal (a browser must reach the
|
||||
// pre-auth surface before it holds a token), so From() is empty there — a public
|
||||
// handler that legitimately honors an authenticated caller resolves it here.
|
||||
//
|
||||
// It is the same fail-closed resolution every gated route runs (one verifier,
|
||||
// one user load, one revocation check); only the outcome differs — a bad bearer
|
||||
// is nil rather than a 401, because the caller's flow continues anonymously.
|
||||
// A handler must therefore treat a nil Principal as "anonymous", never as an
|
||||
// error, and must never widen authority on the strength of this alone: it proves
|
||||
// only WHO the caller is, not that the caller INTENDED this request (the wallet
|
||||
// link branch pairs it with a same-site check for exactly that reason).
|
||||
func Optional(c *zip.Ctx, db orm.DB) *Principal {
|
||||
p, err := principal(c, db)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Fail-closed reasons. The Guard collapses all of them to one opaque 401 so a
|
||||
// prober cannot tell a bad signature from an expired token from a revoked user.
|
||||
var (
|
||||
errNoBearer = errors.New("authz: no bearer")
|
||||
errNoSubject = errors.New("authz: token subject carries no org")
|
||||
errRevoked = errors.New("authz: principal is forbidden or deleted")
|
||||
)
|
||||
|
||||
// isRead reports whether a method addresses its target through the query string
|
||||
// rather than a body: a GET (or HEAD) has no body for the op-invoke seam to
|
||||
// decode, so its target is authorized in the Guard. Every other method carries a
|
||||
// body decoded once by the op and is authorized at that seam.
|
||||
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
|
||||
|
||||
// ReadTarget extracts the (owner, name) a GET addresses, from the query string.
|
||||
// A native typed read files them as `?owner=&name=`; the the legacy surface compat verbs
|
||||
// (get-user, get-organization, …) file them as `?id=<owner>/<name>`. Explicit
|
||||
// owner/name win; the id split is a fallback only when owner is absent, so this
|
||||
// can only make an id-based read's authorization MORE precise than the empty
|
||||
// target it resolves to today (which fail-closed denies every non-super). It
|
||||
// never widens: the tenant rule still pins owner to the principal's org, and the
|
||||
// handler independently re-scopes the query owner through Scope, so a request
|
||||
// that spells one owner in `?owner` and another in `?id` cannot read across
|
||||
// tenants — the authorized owner and the queried owner are both pinned.
|
||||
//
|
||||
// It is exported so the compat read aliases resolve their target through the
|
||||
// SAME function the Guard authorizes with: one extraction, so a handler can
|
||||
// never address a row the Guard did not authorize.
|
||||
func ReadTarget(c *zip.Ctx) (owner, name string) {
|
||||
owner, name = c.Query("owner"), c.Query("name")
|
||||
if owner == "" {
|
||||
if id := c.Query("id"); id != "" {
|
||||
if o, n, ok := strings.Cut(id, "/"); ok && o != "" {
|
||||
return o, n
|
||||
}
|
||||
// A BARE id carries the name alone — `?id=cert-hanzo`, which is how a
|
||||
// relying party asks for the cert its application row names. Previously
|
||||
// this resolved to NO target at all (owner "" AND name ""), so the
|
||||
// authorizer was handed nothing to reason about and fail-closed denied
|
||||
// every caller including the one reading its own. Resolving the name half
|
||||
// can only make the decision MORE precise: an empty owner still fails the
|
||||
// tenant rule (owner != p.Org) and IsReservedOrg(""), so no clause is
|
||||
// widened by knowing the name — only the self-read clause, which pins that
|
||||
// name to the principal's own cert, can act on it.
|
||||
if !strings.Contains(id, "/") {
|
||||
return "", id
|
||||
}
|
||||
}
|
||||
}
|
||||
return owner, name
|
||||
}
|
||||
|
||||
// handlerAuthorizedPrefixes are path subtrees whose target rides in the PATH, not
|
||||
// the query — the Guard authenticates them (a bearer is still required) but does
|
||||
// NOT pre-authorize the read; the handler authorizes on the path id via
|
||||
// authz.Scope. SCIM (RFC 7644, /v1/iam/scim/v2/Users/{id}) is path-targeted, so it
|
||||
// belongs here. This is the read analogue of a write deferring to the op-invoke
|
||||
// seam — the target is authorized where it is bound, not guessed from the query.
|
||||
// get-organization-projects (and its workspace tier, get-organization-workspaces)
|
||||
// is the the legacy surface read verb whose target rides in ?organization= (the
|
||||
// ScopeSwitcher's project/workspace list), not ?owner=/?id=/the path, so the Guard
|
||||
// cannot pre-authorize it generically; the handler scopes it through authz.Scope
|
||||
// instead (the read analogue of SCIM's path-targeted authorization).
|
||||
// get-memberships is the the legacy surface alias of /v1/iam/memberships whose target rides in
|
||||
// ?user=/?org=, so it belongs here for the same reason its REST twin does — the
|
||||
// membership list handler's own scoped() check is the tenant gate.
|
||||
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/", "/v1/iam/get-organization-projects", "/v1/iam/get-organization-workspaces", "/v1/iam/service-accounts", "/v1/iam/memberships", "/v1/iam/get-memberships"}
|
||||
|
||||
// handlerAuthorizedExact are SINGLE routes (not subtrees) the handler authorizes
|
||||
// itself. get-user is here — not a prefix — because "/v1/iam/get-user" IS a prefix
|
||||
// of "/v1/iam/get-users" (the generic, Guard-authorized list): a prefix entry would
|
||||
// silently strip the Guard's read gate from get-users and let a request parameter
|
||||
// narrow rather than deny a cross-tenant list. get-user carries a `?accessKey=`
|
||||
// variant whose target is a secret key (no owner/name for the Guard to authorize),
|
||||
// so the get-user handler authorizes BOTH its variants — the owner/name read through
|
||||
// the SAME authz.Can the Guard would have applied, the key read behind CapKeyResolve.
|
||||
//
|
||||
// resolve-key is here for the same reason: its target is a publishable pk- riding in
|
||||
// ?accessKey= (no owner/name for the Guard to authorize), and its handler authorizes
|
||||
// itself behind CapPublishableResolve, returning ONLY the org — never a principal.
|
||||
var handlerAuthorizedExact = map[string]bool{
|
||||
"/v1/iam/get-user": true,
|
||||
"/v1/iam/resolve-key": true,
|
||||
}
|
||||
|
||||
// pathAuthorized reports whether path is handler-authorized: an exact single-route
|
||||
// match, or under a handler-authorized subtree.
|
||||
func pathAuthorized(path string) bool {
|
||||
if handlerAuthorizedExact[path] {
|
||||
return true
|
||||
}
|
||||
for _, p := range handlerAuthorizedPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// refuse writes the Guard's rejection in the envelope the CALLER can actually
|
||||
// parse, so one surface answers in one shape.
|
||||
//
|
||||
// The the legacy surface-compatible verbs (/v1/iam/get-user, add-organization, …) are a
|
||||
// contract: every client of them branches on a STRING `status` of "ok"/"error" and
|
||||
// reads `msg`. The handlers honour that — get-account answers
|
||||
// {"status":"error","msg":"please sign in first"} — but the Guard short-circuits
|
||||
// BEFORE any handler runs, and zip's own error shape is {"status":401,
|
||||
// "error":"…"}: `status` an int where the client expects a string, and the text
|
||||
// under `error` where the client reads `msg`. So the same endpoint spoke two
|
||||
// languages depending on whether it got far enough to answer for itself, and a
|
||||
// client written against the documented one silently saw neither an ok nor a
|
||||
// recognizable error. The fix belongs here, at the source, not in every client
|
||||
// learning to tolerate both.
|
||||
//
|
||||
// Only the compat surface is reshaped. The native REST/OIDC routes keep zip's
|
||||
// numeric-status error, which is THEIR contract — this is one envelope per
|
||||
// surface, not one envelope everywhere. The HTTP status code is unchanged in both
|
||||
// cases (401/403), so anything reading the code rather than the body is unaffected.
|
||||
func refuse(c *zip.Ctx, status int, msg string) error {
|
||||
if legacyVerb(c.Path()) {
|
||||
return c.JSON(status, httpx.Response{Status: "error", Msg: msg})
|
||||
}
|
||||
if status == 401 {
|
||||
return zip.ErrUnauthorized(msg)
|
||||
}
|
||||
return zip.ErrForbidden(msg)
|
||||
}
|
||||
|
||||
// legacyVerbs are the request-shaped prefixes of the compat surface — the
|
||||
// verb-per-path the legacy surface spelling (get-/add-/update-/delete-) that the console BFF,
|
||||
// the @hanzo/iam SDK and the cloud clients hard-code. The native surface is
|
||||
// noun-shaped (/v1/iam/users, /v1/iam/organizations), so the verb prefix is what
|
||||
// distinguishes the two contracts without a second list to keep in sync.
|
||||
var legacyVerbs = []string{"get-", "add-", "update-", "delete-"}
|
||||
|
||||
// legacyVerb reports whether path is one of the compat verbs.
|
||||
func legacyVerb(path string) bool {
|
||||
const p = "/v1/iam/"
|
||||
if !strings.HasPrefix(path, p) {
|
||||
return false
|
||||
}
|
||||
rest := path[len(p):]
|
||||
for _, v := range legacyVerbs {
|
||||
if strings.HasPrefix(rest, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
// strips them — so authenticating one is a category error: it can only
|
||||
// ever fail. It also fails usefully for nobody, because a 401 preflight
|
||||
// is indistinguishable to the page from "this origin is not allowed",
|
||||
// which is how a legitimately-registered SPA gets told its own IdP is
|
||||
// unreachable. Whether the path is actually open to a browser is CORS's
|
||||
// question, already answered upstream (internal/cors): if it opened the
|
||||
// path it terminated the walk with 204 and we never run; if it did not,
|
||||
// falling through emits no allow-origin header and the browser blocks
|
||||
// the real request anyway. Either way this is not a request to authorize.
|
||||
if c.Method() == http.MethodOptions {
|
||||
return c.Continue()
|
||||
}
|
||||
p, err := principal(c, db)
|
||||
if err != nil {
|
||||
return refuse(c, 401, "authentication required")
|
||||
}
|
||||
// A path-targeted resource (SCIM: /Users/{id}) carries its target in the
|
||||
// PATH, not the query — so, like a write whose target rides in the body, the
|
||||
// Guard authenticates (bearer required, principal attached) and the handler
|
||||
// authorizes via authz.Scope on the path id. The Guard never authorizes an
|
||||
// empty query target for these (which would fail-closed deny every non-super
|
||||
// before the handler could scope). Every other read is authorized here.
|
||||
if !pathAuthorized(c.Path()) {
|
||||
rOwner, rName := ReadTarget(c)
|
||||
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), rOwner, rName) {
|
||||
return refuse(c, 403, "forbidden")
|
||||
}
|
||||
}
|
||||
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
|
||||
return c.Continue()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// A REST read carries its target in the query string, not the body, so its
|
||||
// decoded In is empty and the Guard already authorized it there — such a call is
|
||||
// admitted here (owner == ""). Every write, and any read invoked over MCP (whose
|
||||
// arguments DO decode a target into In), is authorized against authorize().
|
||||
//
|
||||
// Every typed op is authed by construction — the public surface is raw handlers
|
||||
// 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, 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) {
|
||||
return nil // REST read: target rode in the query, authorized by the Guard
|
||||
}
|
||||
p, present := From(ctx)
|
||||
if !present {
|
||||
return zip.ErrForbidden("forbidden") // gated op with no principal: fail closed
|
||||
}
|
||||
if !authorize(p, op.Method, entityOf(op.Path), owner, name) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorize is the pure authorization decision: may p act on a resource owned by
|
||||
// `owner` (named `name`) on the given entity? The order IS the policy:
|
||||
//
|
||||
// 1. SuperAdmin may do anything — the only cross-tenant scope.
|
||||
// 2. A platform-owned resource — one under a RESERVED system org (store.IsReservedOrg:
|
||||
// admin/built-in, the signing owners, PLUS "app", the service-principal org) — is
|
||||
// writable only by a SuperAdmin. This single rule is the signing-cert poisoning
|
||||
// gate, the admin-scoped app/provider registration gate, the built-in-org gap, AND
|
||||
// the service-org ("app") consistency the self-service surfaces already enforce, all
|
||||
// at once: a built-in-org principal is not SuperAdmin (that is admin only), so it
|
||||
// cannot write a built-in-owned signing cert; and no capability app nor "app"-org
|
||||
// admin can land a user under owner="app" (a platform identity) — the raw CRUD now
|
||||
// consults the SAME predicate signup/onboarding do, so the reserved set never
|
||||
// drifts between surfaces.
|
||||
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
|
||||
// empty or foreign owner is refused — the target org is bound to the
|
||||
// principal, never trusted from the request.
|
||||
// 4. Inside its own org, an org admin manages everything; a regular user may
|
||||
// only READ its own user record (self-service). The users entity serves
|
||||
// reads as GET and writes as POST, so gating the self clause to GET keeps a
|
||||
// regular user from writing its own record — a raw entity write would
|
||||
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
|
||||
// is the Phase-5 provision-don't-promote concern; here it is closed by
|
||||
// denial.
|
||||
func authorize(p *Principal, method, entity, owner, name string) bool {
|
||||
if p.Super {
|
||||
return true
|
||||
}
|
||||
// An app may READ THE ROW IT AUTHENTICATED AS, and no other. Reading its own
|
||||
// registration is the ordinary bootstrap of an OIDC relying party — it is how a
|
||||
// client discovers its own cert, redirect URIs and enabled methods — and it
|
||||
// reveals nothing the holder of that client's credential does not already have.
|
||||
//
|
||||
// The owner-pin that closed the "every client credential is a global admin"
|
||||
// escalation is not wrong; it was missing this case, and applications are not in
|
||||
// capFor(), so a confidential client could not read even itself and every cloud
|
||||
// deploy 403'd on its own bootstrap.
|
||||
//
|
||||
// Narrow by construction, in four ways at once: only an app principal (a human's
|
||||
// authority is decided below), only a READ (never a write to its own row — that
|
||||
// would let a client widen its own redirect URIs or grants), only the
|
||||
// applications entity, and only the exact (AppOwner, App) pair the request
|
||||
// authenticated as. Both halves of the key must match, so this is self-read and
|
||||
// not "apps may read applications": a sibling in the same org differs in `name`
|
||||
// and stays refused, and admin/<app> vs <tenant>/<app> — the same NAME under a
|
||||
// different owner — differs in `owner`, so neither direction of that collision
|
||||
// is admitted. That pairing is the same one Allowed() pins capabilities to.
|
||||
if p.App != "" && isRead(method) {
|
||||
// its own application row — both halves of the key must match
|
||||
if entity == "applications" && owner != "" && owner == p.AppOwner && name == p.App {
|
||||
return true
|
||||
}
|
||||
// ...and the ONE signing cert that row references. A relying party cannot
|
||||
// bootstrap without it: InitAuthConfig reads its application, then reads
|
||||
// application.Cert, then InitConfig(cert.Certificate) — so granting only the
|
||||
// application fixes one line and panics identically on the next.
|
||||
//
|
||||
// Scoped to the cert its OWN application names, never "apps may read certs":
|
||||
// name must equal the cert on the authenticated row, so an app cannot walk to
|
||||
// another brand's signing cert. Read-only, and the read is masked anyway
|
||||
// (Cert.Mask blanks PrivateKey and AccessSecret), so what crosses the wire is
|
||||
// the PUBLIC certificate this client already has to trust to verify our
|
||||
// tokens. A bare `?id=cert-hanzo` carries no owner half, so an empty owner is
|
||||
// admitted ONLY here, where the cert NAME is already pinned to this principal.
|
||||
// The owner half varies by CALLER, so all three shapes are admitted — what
|
||||
// pins this read is the NAME, not the owner. ai/internal/iam/cert.go sends
|
||||
// "<IAM_ORG>/<name>" (hanzo/cert-hanzo), GetApplication hardcodes admin/, and
|
||||
// a bare id carries no owner at all. Measured: admin/cert-hanzo and
|
||||
// hanzo/cert-hanzo are two rows seeded 3ms apart carrying the IDENTICAL 4096-bit
|
||||
// modulus, both matching the single JWKS kid=cert-hanzo — so the owner half
|
||||
// selects between duplicates of one keypair, not between different keys.
|
||||
//
|
||||
// name == p.AppCert is the whole gate and it is unchanged: an app reaches the
|
||||
// one cert its own application row names and no other, whichever owner it
|
||||
// spells. Read-only, and Cert.Mask blanks PrivateKey, so this discloses the
|
||||
// PUBLIC key already published at /v1/iam/.well-known/jwks.
|
||||
if entity == "certs" && p.AppCert != "" && name == p.AppCert &&
|
||||
(owner == "" || owner == p.AppOwner || owner == p.Org) {
|
||||
return true
|
||||
}
|
||||
// An org's OWN PaaS machine identity may READ that org's projects, and
|
||||
// nothing else. This is how cloud's platform resolves a tenant's
|
||||
// projects from the canonical store here instead of a second embedded
|
||||
// database — the split-brain where a project created at /v1/iam was
|
||||
// invisible to the PaaS and vice versa.
|
||||
//
|
||||
// Narrow by construction, four ways at once, mirroring the self-read
|
||||
// blocks above: only a READ; only the projects entity; only the
|
||||
// caller's OWN org (owner == p.Org, so one tenant's identity can never
|
||||
// walk another's list); and only the identity the "<org>-platform-kms"
|
||||
// contract names — the same string cloud's SanitizeIdentity recognises
|
||||
// in order to DENY that principal SuperAdmin. The contract is the
|
||||
// grant, stated once; no env allowlist to drift.
|
||||
if entity == "projects" && owner != "" && owner == p.Org &&
|
||||
p.App == p.Org+"-platform-kms" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if store.IsReservedOrg(owner) {
|
||||
// The ONE exception to the reserved-owner gate is the tenant registry: every
|
||||
// organization row is filed under the admin owner, but an org row is the
|
||||
// TENANT'S own record, not platform trust material — a tenant reads its own
|
||||
// org, its admin edits it, and an org-admin-capable confidential client
|
||||
// manages orgs during onboarding (v1 requireAppCapability(CapOrgAdmin)).
|
||||
// Certs, applications, providers, and users under a reserved owner
|
||||
// (admin/built-in/app) stay SuperAdmin-only.
|
||||
if entity != "organizations" {
|
||||
return false
|
||||
}
|
||||
if p.App != "" {
|
||||
return Allowed(p, CapOrgAdmin)
|
||||
}
|
||||
return name == p.Org && (isRead(method) || p.Admin)
|
||||
}
|
||||
// A confidential client's authority is its capability allowlist and nothing
|
||||
// else — never Super, never Admin; an unmapped entity or unset allowlist denies.
|
||||
if p.App != "" {
|
||||
return Allowed(p, capFor(entity))
|
||||
}
|
||||
if owner == "" || owner != p.Org {
|
||||
return false
|
||||
}
|
||||
if p.Admin {
|
||||
return true
|
||||
}
|
||||
return method == "GET" && entity == "users" && name != "" && name == p.User
|
||||
}
|
||||
|
||||
// owned is implemented by a typed input whose authorization target is NOT its
|
||||
// top-level Owner/Name. The user create/update body nests the record under
|
||||
// `user`, so its owner is in.User.Owner, not a top-level field; its AuthzTarget
|
||||
// returns exactly what the handler binds — the handler calls the same method — so
|
||||
// the value authorized is by construction the value written. Any future input
|
||||
// that nests its owner implements this too: it is the ONE contract for nesting,
|
||||
// so the seam never guesses which field the handler uses and never mistakes a
|
||||
// read-only enrichment sub-struct (e.g. an application's resolved certObj, which
|
||||
// carries its OWN owner) for the target.
|
||||
type owned interface {
|
||||
AuthzTarget() (owner, name string)
|
||||
}
|
||||
|
||||
// decodedTarget returns the (owner, name) a decoded request addresses — exactly
|
||||
// the values the handler will bind, read from the SAME decoded struct the handler
|
||||
// runs on, so there is no second parse to diverge from. An input that nests its
|
||||
// owner declares it via owned; every other input files its owner at the top level
|
||||
// (directly, or promoted from an embedded record), read reflectively so no entity
|
||||
// needs bespoke binding and an attacker-supplied nested sub-struct is never a
|
||||
// target.
|
||||
func decodedTarget(in any) (owner, name string) {
|
||||
if o, ok := in.(owned); ok {
|
||||
return o.AuthzTarget()
|
||||
}
|
||||
v := reflect.ValueOf(in)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return "", ""
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return "", ""
|
||||
}
|
||||
return stringField(v, "Owner"), stringField(v, "Name")
|
||||
}
|
||||
|
||||
// stringField returns the string value of the named field (traversing embedded
|
||||
// anonymous fields via FieldByName), or "" when the field is absent or not a
|
||||
// string. FieldByName does not descend named sub-fields, so it reads the record's
|
||||
// own owner, never one nested under an unrelated field.
|
||||
func stringField(v reflect.Value, name string) string {
|
||||
f := v.FieldByName(name)
|
||||
if f.IsValid() && f.Kind() == reflect.String {
|
||||
return f.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// principal resolves the verified bearer into a Principal, failing closed on a
|
||||
// missing/malformed/expired/wrong-key token (oidc.VerifyToken enforces the
|
||||
// algorithm allowlist and trusted signing-cert resolution), a subject with no
|
||||
// org, a store error, or a forbidden/deleted user. Org, Admin, and Super are
|
||||
// read from the LOADED user record — authoritative — never from the token
|
||||
// claims: SuperAdmin is a real, live member of the admin org, not a subject that
|
||||
// merely names one. A subject with no user row (a client_credentials machine
|
||||
// token, or a since-deleted user) authenticates but carries no admin or
|
||||
// SuperAdmin authority and no self-service identity — org-scoped only, which on
|
||||
// the raw CRUD authorizes to nothing until a later phase grants machine
|
||||
// identities explicit scope. This closes the phantom-admin subject: a token for
|
||||
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
|
||||
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
|
||||
if p, ok := app(c, db); ok {
|
||||
return p, nil
|
||||
}
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return nil, errNoBearer
|
||||
}
|
||||
ctx := c.Context()
|
||||
claims, err := oidc.VerifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The subject is the principal's OWN stable identity, set server-side at mint and
|
||||
// signed — a UUID for a v2 token, or "<owner>/<name>" pre-cutover. Resolve it to
|
||||
// the live user through the ONE subject decoder (Id-or-name), and read Org/Admin/
|
||||
// Super from the LOADED record — never from the `owner` claim (the app's org), so
|
||||
// a token whose owner claim names admin but whose subject is a tenant user gets
|
||||
// the tenant's authority, not the claim's (the org-confusion defense).
|
||||
u, err := store.GetUserBySubject(ctx, db, claims.Subject)
|
||||
if err != nil {
|
||||
return nil, err // fail closed: cannot establish the principal
|
||||
}
|
||||
if u != nil {
|
||||
if u.IsForbidden || u.IsDeleted {
|
||||
return nil, errRevoked
|
||||
}
|
||||
return &Principal{Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg}, nil
|
||||
}
|
||||
// No user row. A machine token's subject is "<appOwner>/<appName>" — org-scoped
|
||||
// to the app's owner half, carrying no admin/super authority. Anything else — an
|
||||
// opaque UUID subject with no live user row (a since-deleted user, or a forgery
|
||||
// the trusted-key verify already blocks) — establishes NO principal, fail closed.
|
||||
owner, _, hasSlash := strings.Cut(claims.Subject, "/")
|
||||
if !hasSlash || owner == "" {
|
||||
return nil, errNoSubject
|
||||
}
|
||||
return &Principal{Org: owner}, nil
|
||||
}
|
||||
|
||||
// app resolves an `Authorization: Basic <clientId>:<clientSecret>` credential into
|
||||
// a confidential-client Principal — the transport every live server-side consumer
|
||||
// authenticates with (RFC 6749 §2.3.1 client_secret_basic; cloud reads
|
||||
// IAM_MINT_CLIENT_ID/SECRET and sends exactly this). The application NAME is the
|
||||
// identity, because the capability allowlists key on the name.
|
||||
//
|
||||
// It is deliberately NOT an authority: the returned Principal is never Admin and
|
||||
// never Super, so the ONLY thing it can do is what its name is allowlisted for
|
||||
// (authorize → Allowed). This is what keeps the v1 "every confidential client is a
|
||||
// global admin" hole closed as the transport is re-added.
|
||||
//
|
||||
// Fail-closed: an unparseable header, an unknown clientId, an application with no
|
||||
// registered secret, an empty presented secret (a public client must never
|
||||
// authenticate as an app), or a mismatch all report false — the caller then finds
|
||||
// no bearer either and answers 401. The comparison is constant-time.
|
||||
func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
|
||||
id, secret, ok := httpx.Basic(c)
|
||||
if !ok || id == "" || secret == "" {
|
||||
return nil, false
|
||||
}
|
||||
a, err := store.GetApplicationByClientId(c.Context(), db, id)
|
||||
if err != nil || a == nil || a.ClientSecret == "" {
|
||||
return nil, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(a.ClientSecret), []byte(secret)) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
// AppOwner is the app row's OWNING org (a.Owner: "admin"/"built-in" for a platform
|
||||
// app), NOT a.Organization (the tenant it SERVES). cap.go pins every capability to
|
||||
// this being a reserved signing owner, so a tenant-owned app named/clientId'd like
|
||||
// a console holds nothing. Org carries the served tenant, as before.
|
||||
return &Principal{App: a.Name, AppOwner: a.Owner, AppCert: a.Cert, Org: a.Organization}, true
|
||||
}
|
||||
|
||||
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
|
||||
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
|
||||
// its regular-user self-service rule — so every other segment is treated
|
||||
// uniformly by the tenant rule.
|
||||
func entityOf(path string) string {
|
||||
const p = "/v1/iam/"
|
||||
if !strings.HasPrefix(path, p) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(p):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
return entityNoun(rest)
|
||||
}
|
||||
|
||||
// entityNoun folds the legacy VERB spelling of a path segment onto the entity
|
||||
// noun the policy is written in: get-application -> applications, add-organization
|
||||
// -> organizations. Both surfaces address the SAME rows, so they must resolve to
|
||||
// the same entity — and they did not.
|
||||
//
|
||||
// This is what made the app self-read grant look inert in production. The native
|
||||
// route /v1/iam/applications resolved to "applications" and matched; the compat
|
||||
// alias /v1/iam/get-application resolved to the literal string "get-application",
|
||||
// matched no clause, fell through to the reserved-owner gate and 403'd. Cloud calls
|
||||
// the alias, so the grant never fired on the only path anyone uses.
|
||||
//
|
||||
// It is the wider bug too, not just this grant's: EVERY capability keyed on an
|
||||
// entity was dead on the compat surface, because capFor("add-organization") is not
|
||||
// capFor("organizations"). The allowlists that exist precisely so the brand consoles
|
||||
// can manage orgs during onboarding were being consulted with a key that could never
|
||||
// match. Folding here — the ONE place a path becomes an entity — restores the
|
||||
// documented policy on both surfaces at once rather than teaching each clause two
|
||||
// spellings.
|
||||
func entityNoun(seg string) string {
|
||||
for _, v := range legacyVerbs {
|
||||
if strings.HasPrefix(seg, v) {
|
||||
seg = seg[len(v):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if seg == "" {
|
||||
return ""
|
||||
}
|
||||
// EVERY policy clause is written in the plural — applications, certs,
|
||||
// projects, users, organizations, keys — so every path segment is folded to
|
||||
// the plural, not just the ones that carried a verb prefix.
|
||||
//
|
||||
// Pluralising only after stripping a verb is what split the policy in two.
|
||||
// /v1/iam/get-application folded to "applications" and matched the app
|
||||
// self-read clause; the NATIVE /v1/iam/application carries no verb, fell
|
||||
// through as the singular "application", matched no clause, and hit the
|
||||
// reserved-owner gate — so a relying party could read its own row over the
|
||||
// legacy verb and was refused 403 over the native route. One policy, two
|
||||
// answers, decided by spelling.
|
||||
//
|
||||
// Folding here is safe precisely because the clauses are plural: the only
|
||||
// segments this newly changes are the singular natives (application, cert,
|
||||
// key, user, organization, project), and each folds onto the entity it IS.
|
||||
if !strings.HasSuffix(seg, "s") {
|
||||
seg += "s"
|
||||
}
|
||||
return seg
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// The confidential-client authorization policy: an app principal's ENTIRE
|
||||
// authority is its capability allowlist — never Super, never Admin, never a
|
||||
// tenant. This is the v1 "every client credential is a global admin" hole, held
|
||||
// closed. authorize() IS the decision; this table is its truth for app principals.
|
||||
func TestAuthorizeAppCapabilities(t *testing.T) {
|
||||
// The allowlists reserve each capability to a named admin-owned app.
|
||||
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-team")
|
||||
t.Setenv("IAM_SA_LIST_ALLOWED_APPS", "hanzo-reader")
|
||||
|
||||
console := &Principal{App: "hanzo-console", AppOwner: "admin", Org: "admin"} // admin-owned: user+org admin caps
|
||||
nobody := &Principal{App: "rogue-app", AppOwner: "hanzo", Org: "hanzo"} // in no allowlist
|
||||
// attacker: a tenant that registered <its-org>/hanzo-console — the SAME allow-listed
|
||||
// NAME, but owned by a NON-signing org. The owner-pin (cap.go) denies every capability,
|
||||
// so the public signup→onboard→register-app→Basic-auth escalation is inert.
|
||||
attacker := &Principal{App: "hanzo-console", AppOwner: "evil", Org: "evil"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p *Principal
|
||||
method string
|
||||
entity string
|
||||
owner string
|
||||
name2 string
|
||||
want bool
|
||||
}{
|
||||
// A capability-holding app may act on its mapped entity — cross-tenant by
|
||||
// design (a platform console onboards any customer org).
|
||||
{"console writes users in any org", console, "POST", "users", "orgb", "x", true},
|
||||
{"console writes org (reserved-owner exception)", console, "POST", "organizations", "admin", "hanzo", true},
|
||||
{"console reads org", console, "GET", "organizations", "admin", "hanzo", true},
|
||||
|
||||
// An app NEVER reaches signing material or unmapped entities, allowlisted
|
||||
// or not — capFor has no mapping, so the allowlist is vacuously empty.
|
||||
{"console -> certs denied", console, "POST", "certs", "admin", "k", false},
|
||||
{"console -> providers denied", console, "POST", "providers", "hanzo", "p", false},
|
||||
{"console -> tokens denied", console, "POST", "tokens", "hanzo", "t", false},
|
||||
|
||||
// An app in NO allowlist holds nothing — a leaked credential is inert.
|
||||
{"rogue -> users denied", nobody, "POST", "users", "hanzo", "x", false},
|
||||
{"rogue -> orgs denied", nobody, "POST", "organizations", "admin", "hanzo", false},
|
||||
{"rogue -> own-org users denied", nobody, "POST", "users", "hanzo", "x", false},
|
||||
|
||||
// A user under a reserved owner is NEVER writable by an app — provision,
|
||||
// never promote (no capability moves a user into the admin org).
|
||||
{"console -> admin-org user denied", console, "POST", "users", "admin", "x", false},
|
||||
{"console -> built-in user denied", console, "POST", "users", "built-in", "x", false},
|
||||
// [INFO] consistency: even the LEGIT admin-owned console may not land a user in
|
||||
// the reserved service-principal org "app" — a platform identity, super-only. The
|
||||
// raw CRUD now consults IsReservedOrg, the SAME predicate signup/onboarding use.
|
||||
{"console -> app-org user denied", console, "POST", "users", "app", "x", false},
|
||||
|
||||
// RED PoC, now DENIED: a tenant-owned app spoofing the console NAME holds NOTHING.
|
||||
// The owner-pin refuses a non-signing owner BEFORE any allowlist name match, so a
|
||||
// leaked/forged tenant credential named like the console cannot act on any tenant.
|
||||
{"attacker spoof -> victim users denied", attacker, "POST", "users", "victim", "x", false},
|
||||
{"attacker spoof -> org write denied", attacker, "POST", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> org delete denied", attacker, "DELETE", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> org read denied", attacker, "GET", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> own-org users denied", attacker, "POST", "users", "evil", "x", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
|
||||
t.Fatalf("authorize(App=%q,%s,%s,%s/%s) = %v, want %v",
|
||||
c.p.App, c.method, c.entity, c.owner, c.name2, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An app principal is structurally never Super/Admin, so it can never take the
|
||||
// human privileged paths even if a future bug set the flags.
|
||||
if console.Super || console.Admin {
|
||||
t.Fatal("an app principal must never carry Super/Admin")
|
||||
}
|
||||
|
||||
// RED's four assertions, VERBATIM — the exact PoC principal &Principal{App:"hanzo-console"}
|
||||
// (no owning signing org). Every one fired == true before the owner-pin; every one must
|
||||
// be false now. A bare app principal is inert regardless of the NAME it presents.
|
||||
red := &Principal{App: "hanzo-console"}
|
||||
for _, a := range []struct{ method, entity, owner, name string }{
|
||||
{"POST", "users", "victim", "x"},
|
||||
{"POST", "organizations", "admin", "victim"},
|
||||
{"DELETE", "organizations", "admin", "victim"},
|
||||
{"GET", "organizations", "admin", "victim"},
|
||||
} {
|
||||
if authorize(red, a.method, a.entity, a.owner, a.name) {
|
||||
t.Fatalf("RED PoC REOPENED: authorize(App=hanzo-console,%s,%s,%s/%s) GRANTED — the owner-pin failed",
|
||||
a.method, a.entity, a.owner, a.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capability primitives, fail-secure to the letter.
|
||||
func TestCapabilityPrimitives(t *testing.T) {
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console, brand-console")
|
||||
|
||||
t.Run("Allowed named", func(t *testing.T) {
|
||||
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapOrgAdmin) {
|
||||
t.Fatal("a named, admin-owned app must hold its capability")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed unnamed denied", func(t *testing.T) {
|
||||
if Allowed(&Principal{App: "other", AppOwner: "admin"}, CapOrgAdmin) {
|
||||
t.Fatal("an unnamed app must hold nothing") // admin-owned, so the NAME check alone denies
|
||||
}
|
||||
})
|
||||
t.Run("Allowed unset env denied", func(t *testing.T) {
|
||||
if Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapKeyMint) { // IAM_KEY_MINT_ALLOWED_APPS unset here
|
||||
t.Fatal("an unset allowlist must deny every app")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed owner-pin denies a non-signing owner", func(t *testing.T) {
|
||||
// hanzo-console IS on IAM_ORG_ADMIN_APPS, but these rows are NOT admin/built-in owned.
|
||||
if Allowed(&Principal{App: "hanzo-console", AppOwner: "hanzo"}, CapOrgAdmin) {
|
||||
t.Fatal("a tenant-owned app must hold nothing even with an allow-listed name")
|
||||
}
|
||||
if Allowed(&Principal{App: "hanzo-console"}, CapOrgAdmin) { // AppOwner "" — no owning signing org at all
|
||||
t.Fatal("an app with no owning signing org must hold nothing")
|
||||
}
|
||||
// built-in is the other reserved signing owner — a built-in-owned allow-listed app is legit.
|
||||
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "built-in"}, CapOrgAdmin) {
|
||||
t.Fatal("a built-in-owned allow-listed app must hold its capability")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed non-app is vacuous", func(t *testing.T) {
|
||||
if !Allowed(&Principal{Org: "hanzo"}, CapOrgAdmin) {
|
||||
t.Fatal("a human holds capabilities vacuously; the org policy decides")
|
||||
}
|
||||
})
|
||||
t.Run("BoundToOrg prefix", func(t *testing.T) {
|
||||
p := &Principal{App: "hanzo-team"}
|
||||
if !BoundToOrg(p, "hanzo") {
|
||||
t.Fatal("hanzo-team must be bound to hanzo")
|
||||
}
|
||||
if BoundToOrg(p, "lux") {
|
||||
t.Fatal("hanzo-team must NOT be bound to lux")
|
||||
}
|
||||
if BoundToOrg(&Principal{App: "hanzo"}, "hanzo") {
|
||||
t.Fatal("an exact-name app (no agent segment) is bound to nothing")
|
||||
}
|
||||
})
|
||||
t.Run("capFor mapping", func(t *testing.T) {
|
||||
if capFor("organizations") != CapOrgAdmin || capFor("users") != CapUserAdmin {
|
||||
t.Fatal("org/user entities must map to their capability")
|
||||
}
|
||||
if capFor("certs") != (Cap{}) || capFor("providers") != (Cap{}) {
|
||||
t.Fatal("an unmapped entity must map to the empty (deny-all) capability")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The eight required cases, each through the real registered router. Sub names map
|
||||
// to seeded principals: admin/root = SuperAdmin, hanzo/boss = org admin,
|
||||
// hanzo/alice = regular user, orgb/bob = a foreign org's admin.
|
||||
|
||||
// 1. An unauthenticated CRUD write is refused before any handler runs.
|
||||
func TestUnauthenticatedWriteIs401(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user", "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
{"write cert", "POST", "/v1/iam/certs", cert("admin", signingKid)},
|
||||
{"register app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x"}},
|
||||
{"delete user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
|
||||
{"update cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"create org", "POST", "/v1/iam/organizations", map[string]any{"owner": "admin", "name": "x"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, "", c.body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s no bearer = %d, want 401", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. A valid principal in orgB writing an orgA-owned entity is refused (tenant
|
||||
// isolation): the target org is bound to the principal, never the body.
|
||||
func TestCrossOrgWriteIs403(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bob := h.token(t, "orgb/bob") // org admin, but of orgb
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in hanzo", "POST", "/v1/iam/users", user("hanzo", "mole")},
|
||||
{"update user in hanzo", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
|
||||
{"delete user in hanzo", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
|
||||
{"create role in hanzo", "POST", "/v1/iam/roles", map[string]any{"owner": "hanzo", "name": "r"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, bob, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("orgb principal %s %s = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. THE poisoning gate. A non-SuperAdmin — org admin OR regular user OR a
|
||||
// built-in-org member — writing an admin/built-in-owned signing cert is refused.
|
||||
// Every cert write verb is covered, and the update/delete target the LIVE
|
||||
// signing cert, so a bypass would truly overwrite the platform key.
|
||||
func TestSigningCertPoisoningIs403(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
principals := map[string]string{
|
||||
"org admin (hanzo/boss)": h.token(t, "hanzo/boss"),
|
||||
"regular user (hanzo/alice)": h.token(t, "hanzo/alice"),
|
||||
"built-in member (built-in/svc)": h.token(t, "built-in/svc"),
|
||||
}
|
||||
writes := []struct {
|
||||
name, path string
|
||||
body any
|
||||
}{
|
||||
{"create admin cert", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"overwrite live admin cert", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"delete live admin cert", "/v1/iam/certs/delete", map[string]any{"owner": "admin", "name": signingKid}},
|
||||
{"create built-in cert", "/v1/iam/certs", cert("built-in", "cert-forge")},
|
||||
{"overwrite built-in cert", "/v1/iam/certs/update", cert("built-in", "anything")},
|
||||
}
|
||||
for who, tok := range principals {
|
||||
for _, w := range writes {
|
||||
t.Run(who+" "+w.name, func(t *testing.T) {
|
||||
if got := h.do(t, "POST", w.path, tok, w.body); got != http.StatusForbidden {
|
||||
t.Fatalf("%s writing %s = %d, want 403 (poisoning gate)", who, w.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. A SuperAdmin (org == admin) may write the admin signing cert and act across
|
||||
// any org. The guard admits it; the handler then succeeds (2xx). The rotation
|
||||
// case overwrites the LIVE signing cert with a complete body (key preserved) —
|
||||
// the legitimate operation the poisoning gate exists to reserve to SuperAdmins.
|
||||
func TestSuperAdminWritesAdminCertAndCrossOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
rotate := map[string]any{
|
||||
"owner": "admin", "name": signingKid,
|
||||
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
|
||||
}
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create a new admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-fresh")},
|
||||
{"rotate the live admin signing cert", "POST", "/v1/iam/certs/update", rotate},
|
||||
{"create a user in any org", "POST", "/v1/iam/users", user("hanzo", "hire-by-root")},
|
||||
{"create a user in another org", "POST", "/v1/iam/users", user("orgb", "hire-by-root")},
|
||||
{"register an admin-owned app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "root-app", "clientId": "root-app"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := h.do(t, c.method, c.path, root, c.body)
|
||||
if got < 200 || got >= 300 {
|
||||
t.Fatalf("SuperAdmin %s %s = %d, want 2xx", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. An org admin manages its OWN org's users and apps (2xx) but not another
|
||||
// org's (403). This is the org-admin tier: org-scoped, never cross-tenant.
|
||||
func TestOrgAdminManagesOwnOrgOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
|
||||
allow := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in own org", "POST", "/v1/iam/users", user("hanzo", "newhire")},
|
||||
{"update self org's user", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
|
||||
{"register app in own org", "POST", "/v1/iam/application", map[string]any{"owner": "hanzo", "name": "hanzo-app", "clientId": "hanzo-app"}},
|
||||
}
|
||||
for _, c := range allow {
|
||||
t.Run("allow/"+c.name, func(t *testing.T) {
|
||||
got := h.do(t, c.method, c.path, boss, c.body)
|
||||
if got < 200 || got >= 300 {
|
||||
t.Fatalf("org admin %s %s (own org) = %d, want 2xx", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deny := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in another org", "POST", "/v1/iam/users", user("orgb", "mole")},
|
||||
{"register app in another org", "POST", "/v1/iam/application", map[string]any{"owner": "orgb", "name": "x", "clientId": "x"}},
|
||||
{"write a platform (admin) app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x", "clientId": "x"}},
|
||||
}
|
||||
for _, c := range deny {
|
||||
t.Run("deny/"+c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, boss, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("org admin %s %s (foreign) = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. A regular user may read its own user record (guard admits it) but not touch
|
||||
// another's, and may NOT write even its own record — a raw self-write would let
|
||||
// it carry isAdmin and self-promote, so writes are refused outright.
|
||||
func TestRegularUserSelfServiceOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
|
||||
// Reading own record: the guard admits it (not 401/403). The Phase-1 GET
|
||||
// handler binds no query, so the status is the handler's, never the guard's
|
||||
// forbid — the point here is that the guard did NOT block self-read.
|
||||
if got := h.do(t, "GET", "/v1/iam/users/get?owner=hanzo&name=alice", alice, nil); got == http.StatusForbidden || got == http.StatusUnauthorized {
|
||||
t.Fatalf("regular self-read = %d, want the guard to admit it (not 401/403)", got)
|
||||
}
|
||||
|
||||
// Everything else a regular user might try is refused.
|
||||
deny := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"read another user", "GET", "/v1/iam/users/get?owner=hanzo&name=boss", nil},
|
||||
{"list the org's users", "GET", "/v1/iam/users?owner=hanzo", nil},
|
||||
{"update own record (self-promote)", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
|
||||
{"create a user", "POST", "/v1/iam/users", user("hanzo", "puppet")},
|
||||
{"delete another user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "boss"}},
|
||||
{"read another org", "GET", "/v1/iam/users/get?owner=orgb&name=bob", nil},
|
||||
}
|
||||
for _, c := range deny {
|
||||
t.Run("deny/"+c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, alice, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("regular user %s %s = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Public routes are reachable with NO bearer — the pre-auth OIDC/OAuth and
|
||||
// front-door surface a browser must reach before it holds a token. "Reachable"
|
||||
// means NOT the guard's 401: the endpoint's own handler answers (which may be a
|
||||
// 400 for a missing param — that is the handler, past the guard).
|
||||
func TestPublicRoutesNeedNoBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
public := []struct{ method, path string }{
|
||||
{"GET", "/.well-known/openid-configuration"},
|
||||
{"GET", "/v1/iam/.well-known/openid-configuration"},
|
||||
{"GET", "/v1/iam/.well-known/jwks"},
|
||||
{"GET", "/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (root)
|
||||
{"GET", "/v1/iam/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (v1)
|
||||
{"POST", "/v1/iam/login"},
|
||||
{"GET", "/v1/iam/oauth/authorize"},
|
||||
{"POST", "/v1/iam/oauth/token"},
|
||||
{"GET", "/v1/iam/get-app-login"},
|
||||
{"GET", "/v1/iam/auth/methods"},
|
||||
{"POST", "/v1/iam/oauth/logout"},
|
||||
// The front-door session/identity surface — each self-resolves the caller
|
||||
// (session cookie, else bearer) and answers anonymously (200 {status:error}
|
||||
// or a handler 400), never the Guard's 401. These are the routes the old
|
||||
// publicPaths list had to be patched to include; now they are public purely
|
||||
// because oidc.Route registers them on the pre-Guard group.
|
||||
{"GET", "/v1/iam/get-account"},
|
||||
{"POST", "/v1/iam/signin"},
|
||||
{"GET", "/v1/iam/whoami"},
|
||||
{"GET", "/v1/iam/linked-accounts"},
|
||||
{"POST", "/v1/iam/signup"},
|
||||
{"POST", "/v1/iam/send-verification-code"},
|
||||
{"POST", "/v1/iam/update-preferences"},
|
||||
}
|
||||
for _, c := range public {
|
||||
t.Run(c.method+" "+c.path, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, "", map[string]any{}); got == http.StatusUnauthorized {
|
||||
t.Fatalf("public %s %s = 401, want the endpoint reachable without a bearer", c.method, c.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
// userinfo is bearer-gated but self-verifying: no bearer → its OWN 401
|
||||
// (WWW-Authenticate), which is correct and must not be double-gated away.
|
||||
if got := h.do(t, "GET", "/v1/iam/oauth/userinfo", "", nil); got != http.StatusUnauthorized {
|
||||
t.Fatalf("userinfo no bearer = %d, want its own 401", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Bad bearers are refused with the same opaque 401 (no oracle): expired,
|
||||
// wrong algorithm (HMAC / none — never in the allowlist), a kid that names no
|
||||
// trusted cert, and a good-shape token under the wrong key. This reuses the
|
||||
// Phase-2 verifier defenses verbatim.
|
||||
func TestBadBearersAre401(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
other := genRSA(t)
|
||||
path, body := "/v1/iam/users", user("hanzo", "x")
|
||||
|
||||
bad := map[string]string{
|
||||
"expired": h.mint(t, "admin/root", time.Now().Add(-time.Hour)),
|
||||
"forged kid": mintKid(t, h.key, "cert-nonexistent", "admin/root"),
|
||||
"wrong key": mintKid(t, other, signingKid, "admin/root"),
|
||||
"hmac alg": signHS256(t, signingKid, "admin/root"),
|
||||
"alg none": forgeNone(signingKid, "admin/root"),
|
||||
"garbage": "not.a.jwt",
|
||||
}
|
||||
for name, tok := range bad {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := h.do(t, "POST", path, tok, body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("bad bearer %q = %d, want 401", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A revoked (forbidden) user's otherwise-valid token is refused too.
|
||||
t.Run("revoked user", func(t *testing.T) {
|
||||
if got := h.do(t, "POST", path, h.token(t, "hanzo/ghost"), body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked user = %d, want 401", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Org-confusion escalation defense: a token minted through a SHARED admin-org
|
||||
// app carries owner/organization = "admin" while its subject is a tenant user.
|
||||
// The guard authorizes from the subject (the real user's org), never the owner
|
||||
// claim, so this token is a hanzo REGULAR user — it cannot write an admin cert
|
||||
// or reach across orgs, exactly as if the misleading claim were absent.
|
||||
func TestOwnerClaimCannotEscalate(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// alice is a regular hanzo user; the token lies that owner == admin.
|
||||
tok := h.sharedAppToken(t, "hanzo/alice", "admin")
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"write admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"overwrite live admin cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"create a user cross-org", "POST", "/v1/iam/users", user("orgb", "mole")},
|
||||
{"promote self in own org", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, tok, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("owner-claim=admin %s %s = %d, want 403 (claim must not escalate)", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A verified token whose subject names NO live user — a machine token, a
|
||||
// since-deleted user, or a forged-looking "admin/<nobody>" — authenticates but
|
||||
// carries no authority: SuperAdmin requires a real member of the admin org, so
|
||||
// the phantom-admin subject is refused everywhere.
|
||||
func TestPhantomSubjectHasNoAuthority(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ghostAdmin := h.token(t, "admin/nobody") // no such user seeded
|
||||
ghostTenant := h.token(t, "hanzo/nobody")
|
||||
cases := []struct {
|
||||
name, tok, method, path string
|
||||
body any
|
||||
}{
|
||||
{"phantom admin -> admin cert", ghostAdmin, "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"phantom admin -> user in admin org", ghostAdmin, "POST", "/v1/iam/users", user("admin", "x")},
|
||||
{"phantom admin -> user in a tenant", ghostAdmin, "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
{"phantom tenant -> user in own org", ghostTenant, "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, c.tok, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("%s = %d, want 403 (phantom subject has no authority)", c.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The framework's generic side doors (MCP tool-call, OpenAPI doc) are gated by
|
||||
// the same fail-closed default — proven on a REAL, installed route and a REAL
|
||||
// tool INVOCATION, not just the envelope path. newHarness calls app.Prepare(), so
|
||||
// /mcp and /openapi are actually registered (the old test hit a route that was
|
||||
// never registered, so the guard's 401 masked the fact the invocation was untested),
|
||||
// and the tool id is the framework's real one (post_v1_iam_certs), so a
|
||||
// regression that let a tool arguments-mask through would FAIL here, not pass.
|
||||
func TestFrameworkSideDoorsAreGated(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
forge := cert("admin", "cert-forge") // {owner:"admin", …} — the poisoning target
|
||||
|
||||
// No bearer reaches /mcp at all: the guard authenticates the envelope before
|
||||
// any dispatch, so it is 401 — never an unauthorized invocation, never a 404.
|
||||
if got := h.do(t, "POST", "/mcp", "", mcpEnvelope("post_v1_iam_certs", forge)); got != http.StatusUnauthorized {
|
||||
t.Fatalf("POST /mcp no bearer = %d, want 401 (guard fail-closed)", got)
|
||||
}
|
||||
// The OpenAPI doc — now a real installed route — is gated too.
|
||||
if got := h.do(t, "GET", "/.well-known/openapi.json", "", nil); got != http.StatusUnauthorized {
|
||||
t.Fatalf("GET openapi.json no bearer = %d, want 401", got)
|
||||
}
|
||||
|
||||
// A non-SuperAdmin driving the REAL cert tool is refused at the op-invoke seam
|
||||
// (isError), and — the assertion that matters — NOTHING is written.
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP post_v1_iam_certs (non-super) = status %d isError %v, want 200/true (refused at op seam)", status, isErr)
|
||||
}
|
||||
if h.certExists(t, "admin", "cert-forge") {
|
||||
t.Fatal("MCP cert-forge PERSISTED an admin-owned cert — the /mcp side door is OPEN")
|
||||
}
|
||||
}
|
||||
|
||||
// THE critical bug (finding #1), proven closed at the REST seam. The users entity
|
||||
// is the one input that nests its owner, so an org admin who masks a benign
|
||||
// top-level owner over a nested admin/isAdmin record must NOT create a platform
|
||||
// SuperAdmin. The write is refused (403) AND — the assertion the vacuous test
|
||||
// lacked — the store holds no such row afterward. Query the store, not the status.
|
||||
func TestUserOwnerMaskIsRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss") // org admin of hanzo — authorized for "hanzo" only
|
||||
|
||||
// The PoC verbatim: top-level owner is the attacker's OWN org (which the guard
|
||||
// would authorize), the nested record targets the reserved admin org with
|
||||
// isAdmin — a platform SuperAdmin (owner=="admin" IS the predicate) if it landed.
|
||||
createMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
|
||||
"password": "x",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users", boss, createMask); got != http.StatusForbidden {
|
||||
t.Fatalf("users create owner-mask = %d, want 403", got)
|
||||
}
|
||||
if h.userExists(t, "admin", "red-super") {
|
||||
t.Fatal("owner-mask PERSISTED admin/red-super — total-account-takeover path is OPEN")
|
||||
}
|
||||
|
||||
// The same mask, aimed cross-tenant: inject a user into a foreign org.
|
||||
crossOrgMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "orgb", "name": "mole"},
|
||||
"password": "x",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users", boss, crossOrgMask); got != http.StatusForbidden {
|
||||
t.Fatalf("users create cross-org mask = %d, want 403", got)
|
||||
}
|
||||
if h.userExists(t, "orgb", "mole") {
|
||||
t.Fatal("owner-mask injected a user into orgb (cross-tenant)")
|
||||
}
|
||||
|
||||
// Hijack an EXISTING admin-org user via /users/update (nested owner=admin):
|
||||
// refused, and the victim's privilege/credentials are untouched.
|
||||
hijack := map[string]any{
|
||||
"user": map[string]any{"owner": "admin", "name": "root", "isAdmin": true},
|
||||
"password": "attacker-chosen",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users/update", boss, hijack); got != http.StatusForbidden {
|
||||
t.Fatalf("users update hijack of admin/root = %d, want 403", got)
|
||||
}
|
||||
if h.userIsAdmin(t, "admin", "root") {
|
||||
t.Fatal("update hijack flipped admin/root.isAdmin — privilege takeover via /users/update")
|
||||
}
|
||||
}
|
||||
|
||||
// The MCP arguments-mask (finding #2), proven closed at the SAME op-invoke seam —
|
||||
// the design claim "the guard gates /mcp" made real, independent of the prod
|
||||
// MCP.Disabled flag (this harness leaves MCP ENABLED). A non-SuperAdmin driving
|
||||
// the real tools with admin-targeted arguments is refused and writes nothing; a
|
||||
// SuperAdmin drives the same tool successfully, so the seam refuses by AUTHORITY,
|
||||
// not by blanket-denying every MCP call.
|
||||
func TestMCPArgumentsMaskIsRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
attackerPEM := rsaKeyToPEM(t, genRSA(t))
|
||||
|
||||
// a) cert-forge over MCP arguments: an admin signing cert with an attacker key.
|
||||
forge := map[string]any{
|
||||
"owner": "admin", "name": "cert-forge",
|
||||
"cryptoAlgorithm": "RS256", "privateKey": attackerPEM,
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP cert-forge (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
|
||||
}
|
||||
if h.certExists(t, "admin", "cert-forge") {
|
||||
t.Fatal("MCP cert-forge PERSISTED an admin signing cert with an attacker key")
|
||||
}
|
||||
|
||||
// b) the users owner-mask over MCP arguments: a nested admin SuperAdmin record.
|
||||
userMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
|
||||
"password": "x",
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_users", userMask); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP users owner-mask (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
|
||||
}
|
||||
if h.userExists(t, "admin", "red-super") {
|
||||
t.Fatal("MCP users owner-mask PERSISTED admin/red-super — total takeover via /mcp")
|
||||
}
|
||||
|
||||
// Control: a SuperAdmin drives the SAME cert tool successfully — the seam
|
||||
// discriminates by authority; it does not just refuse everything over MCP.
|
||||
root := h.token(t, "admin/root")
|
||||
legit := map[string]any{
|
||||
"owner": "admin", "name": "cert-legit",
|
||||
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, root, "post_v1_iam_certs", legit); status != http.StatusOK || isErr {
|
||||
t.Fatalf("MCP cert create by SuperAdmin = status %d isError %v, want 200/false (allowed)", status, isErr)
|
||||
}
|
||||
if !h.certExists(t, "admin", "cert-legit") {
|
||||
t.Fatal("SuperAdmin MCP cert create did not persist — the seam is over-refusing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// 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 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
|
||||
// oidc.VerifyToken the guard reuses — nothing here is mocked.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo" // the seeded admin signing cert's name = JWKS kid
|
||||
|
||||
// Two RSA keys, generated once for the whole suite: the trust-anchor key the
|
||||
// signing cert holds, and a distinct "other" key for the wrong-key bearer test.
|
||||
// Keygen is the slow part and the crypto under test is identical whichever key
|
||||
// it is, so caching them keeps the suite (and -race) fast.
|
||||
var (
|
||||
anchorKeyOnce, otherKeyOnce sync.Once
|
||||
anchorKey, otherKey *rsa.PrivateKey
|
||||
)
|
||||
|
||||
func trustKey() *rsa.PrivateKey {
|
||||
anchorKeyOnce.Do(func() { anchorKey = mustRSA() })
|
||||
return anchorKey
|
||||
}
|
||||
|
||||
func mustRSA() *rsa.PrivateKey {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// harness holds the registered app, the RSA key the signing cert holds (so a test
|
||||
// can mint a token any principal would carry), and the store (so a test can
|
||||
// assert that a refused write persisted NOTHING — the real security property, not
|
||||
// just a status code).
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// userExists reports whether a user row (owner, name) is persisted — used to
|
||||
// prove a refused create/update wrote nothing.
|
||||
func (h *harness) userExists(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
return u != nil
|
||||
}
|
||||
|
||||
// certExists reports whether a cert row (owner, name) is persisted.
|
||||
func (h *harness) certExists(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
c, err := store.GetCert(context.Background(), h.db, owner, name)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup cert %s/%s: %v", owner, name, err)
|
||||
}
|
||||
return c != nil
|
||||
}
|
||||
|
||||
// userIsAdmin reports the persisted isAdmin flag of (owner, name) — used to prove
|
||||
// a refused update did NOT flip a victim's privilege.
|
||||
func (h *harness) userIsAdmin(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("expected user %s/%s to exist: %v", owner, name, err)
|
||||
}
|
||||
return u.IsAdmin
|
||||
}
|
||||
|
||||
// newHarness opens a fresh SQLite store, seeds the trust anchor (an admin-owned
|
||||
// RS256 signing cert) plus a cast of principals across three orgs, and registers
|
||||
// the full router — guard and all. MCP is left ENABLED here (unlike prod) so the
|
||||
// tests prove the guard, not a disabled feature, closes the /mcp side door.
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds() // force kind registration
|
||||
key := trustKey()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "authz.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Trust anchor: the admin-owned signing cert the verifier and JWKS trust.
|
||||
// Poisoning tests target THIS row, so a bypassed guard would really overwrite
|
||||
// the live signing key.
|
||||
seedCert(t, db, "admin", signingKid, rsaKeyToPEM(t, key))
|
||||
|
||||
// Principals: one per scope, plus a revoked user and a cross-tenant org.
|
||||
seedUser(t, db, "admin", "root", false, false, false) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true, false, false) // org admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false, false, false) // regular user in hanzo
|
||||
seedUser(t, db, "orgb", "bob", true, false, false) // org admin of orgb (cross-tenant)
|
||||
seedUser(t, db, "hanzo", "ghost", true, true, false) // forbidden — revoked
|
||||
seedUser(t, db, "built-in", "svc", true, false, false) // built-in org, NOT SuperAdmin
|
||||
|
||||
app := zip.New(zip.Config{AppName: "authz-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
// Install the deferred framework projections (/mcp, /openapi) for real, so the
|
||||
// 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.
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
// mint signs an RS256 bearer for subject `sub` (an "owner/name") with the given
|
||||
// expiry, under the trusted kid — the exact shape a real token carries.
|
||||
func (h *harness) mint(t *testing.T, sub string, exp time.Time) string {
|
||||
t.Helper()
|
||||
return signRS256(t, h.key, signingKid, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": exp.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// token is a convenience for a valid, hour-long bearer for sub.
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
return h.mint(t, sub, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
// sharedAppToken mints a valid bearer whose owner/organization claims say
|
||||
// ownerClaim (as a token minted through a SHARED admin-org app would) while the
|
||||
// subject names a different, tenant user. The guard must authorize from the
|
||||
// subject, never these claims — the org-confusion escalation defense.
|
||||
func (h *harness) sharedAppToken(t *testing.T, sub, ownerClaim string) string {
|
||||
t.Helper()
|
||||
return signRS256(t, h.key, signingKid, jwt.MapClaims{
|
||||
"sub": sub, "owner": ownerClaim, "organization": ownerClaim, "exp": future(),
|
||||
})
|
||||
}
|
||||
|
||||
// do issues one request through the real router and returns the status code.
|
||||
func (h *harness) do(t *testing.T, method, path, bearer string, body any) int {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// mcpEnvelope builds a JSON-RPC 2.0 tools/call for the framework tool `tool`
|
||||
// (its real op id, e.g. "post_v1_iam_certs") with `args` as the tool arguments —
|
||||
// the same body an MCP agent would POST to /mcp.
|
||||
func mcpEnvelope(tool string, args any) map[string]any {
|
||||
return map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args},
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolCall fires an MCP tools/call for `tool` with `args` through the REAL
|
||||
// registered /mcp route and reports the HTTP status plus whether the op-invoke
|
||||
// authorizer refused it. A refusal at the op seam surfaces as an isError result
|
||||
// with HTTP 200 (MCP reports handler errors in-band), never a transport 403, so
|
||||
// a refused write shows up as isError==true — the status stays 200.
|
||||
func (h *harness) mcpToolCall(t *testing.T, bearer, tool string, args any) (status int, isError bool) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(mcpEnvelope(tool, args))
|
||||
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("mcp tools/call %s: %v", tool, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out struct {
|
||||
Result struct {
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out.Result.IsError
|
||||
}
|
||||
|
||||
// ---- seed helpers ----------------------------------------------------------
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin, forbidden, deleted bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin, u.IsForbidden, u.IsDeleted = admin, forbidden, deleted
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func rsaKeyToPEM(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
|
||||
func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func future() int64 { return time.Now().Add(time.Hour).Unix() }
|
||||
|
||||
// mintKid signs an hour-long RS256 token for sub under an arbitrary key and kid,
|
||||
// for the forged-kid and wrong-key bearer tests.
|
||||
func mintKid(t *testing.T, key *rsa.PrivateKey, kid, sub string) string {
|
||||
return signRS256(t, key, kid, jwt.MapClaims{"sub": sub, "exp": future()})
|
||||
}
|
||||
|
||||
// genRSA returns the suite's cached "other" key — a valid key that is NOT the
|
||||
// trust anchor, for the wrong-signature bearer test.
|
||||
func genRSA(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
otherKeyOnce.Do(func() { otherKey = mustRSA() })
|
||||
return otherKey
|
||||
}
|
||||
|
||||
// signHS256 forges an HMAC-signed token carrying the trusted kid. The verifier's
|
||||
// algorithm allowlist has no HMAC family, so it is rejected before any key is
|
||||
// consulted (the classic alg-confusion downgrade, closed).
|
||||
func signHS256(t *testing.T, kid, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub, "exp": future()})
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString([]byte("attacker-chosen-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("hs256 sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// forgeNone hand-builds an alg:none token (header.claims. with an empty
|
||||
// signature) — the unsigned-token attack. "none" is absent from the allowlist,
|
||||
// so it never verifies.
|
||||
func forgeNone(kid, sub string) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
head := enc(map[string]any{"alg": "none", "typ": "JWT", "kid": kid})
|
||||
body := enc(map[string]any{"sub": sub, "exp": future()})
|
||||
return head + "." + body + "."
|
||||
}
|
||||
|
||||
// cert is a minimal signing-cert create/update/delete body.
|
||||
func cert(owner, name string) map[string]any {
|
||||
return map[string]any{"owner": owner, "name": name, "cryptoAlgorithm": "RS256"}
|
||||
}
|
||||
|
||||
// user wraps a create/update user body ({user:{...}, password}).
|
||||
func user(owner, name string) map[string]any {
|
||||
return map[string]any{"user": map[string]any{"owner": owner, "name": name}, "password": "x"}
|
||||
}
|
||||
|
||||
// A CORS preflight carries no credentials — the browser strips them — so the
|
||||
// Guard must never answer one with 401. It used to, which is indistinguishable
|
||||
// to the page from "your origin is not allowed": a registered console asking
|
||||
// its own IdP "which orgs am I in?" got a failed preflight and rendered an
|
||||
// empty org switcher, with nothing in the network log but a 401 on OPTIONS.
|
||||
//
|
||||
// The pairing is the point. Opening the preflight must not open the DATA, so
|
||||
// each case also asserts the real GET is still refused without a bearer.
|
||||
func TestGuard_NeverAuthenticatesAPreflight(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, path := range []string{
|
||||
"/v1/iam/get-organizations",
|
||||
"/v1/iam/get-organization",
|
||||
"/v1/iam/get-users",
|
||||
} {
|
||||
if got := h.do(t, "OPTIONS", path, "", nil); got == 401 {
|
||||
t.Errorf("OPTIONS %s answered 401: a preflight has no credentials to "+
|
||||
"reject, and the browser reads this as origin-not-allowed", path)
|
||||
}
|
||||
if got := h.do(t, "GET", path, "", nil); got != 401 {
|
||||
t.Errorf("GET %s without a bearer = %d, want 401: letting the preflight "+
|
||||
"through must not let the READ through", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// The pure policy, tested exhaustively and independent of HTTP. authorize IS the
|
||||
// security decision; this table is its full truth.
|
||||
func TestAuthorizePolicy(t *testing.T) {
|
||||
super := &Principal{Org: "admin", User: "root", Super: true}
|
||||
orgAdmin := &Principal{Org: "hanzo", User: "boss", Admin: true}
|
||||
regular := &Principal{Org: "hanzo", User: "alice"}
|
||||
builtin := &Principal{Org: "built-in", User: "svc", Admin: true} // NOT super
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p *Principal
|
||||
method string
|
||||
entity string
|
||||
owner string
|
||||
name2 string
|
||||
want bool
|
||||
}{
|
||||
// SuperAdmin: unrestricted, including the reserved owners and cross-org.
|
||||
{"super writes admin cert", super, "POST", "certs", "admin", "k", true},
|
||||
{"super writes built-in cert", super, "POST", "certs", "built-in", "k", true},
|
||||
{"super cross-org user", super, "POST", "users", "orgb", "x", true},
|
||||
|
||||
// Poisoning gate: no non-super may write a reserved-owner resource.
|
||||
{"org admin -> admin cert", orgAdmin, "POST", "certs", "admin", "k", false},
|
||||
{"org admin -> built-in cert", orgAdmin, "POST", "certs", "built-in", "k", false},
|
||||
{"regular -> admin cert", regular, "POST", "certs", "admin", "k", false},
|
||||
{"built-in member -> built-in cert", builtin, "POST", "certs", "built-in", "k", false},
|
||||
{"built-in member -> admin app", builtin, "POST", "application", "admin", "a", false},
|
||||
|
||||
// Tenant isolation: own org only.
|
||||
{"org admin own org", orgAdmin, "POST", "users", "hanzo", "x", true},
|
||||
{"org admin foreign org", orgAdmin, "POST", "users", "orgb", "x", false},
|
||||
{"org admin empty owner", orgAdmin, "POST", "certs", "", "k", false},
|
||||
|
||||
// Regular user: read own record only; no writes, no others, no self-promote.
|
||||
{"regular read own", regular, "GET", "users", "hanzo", "alice", true},
|
||||
{"regular read other", regular, "GET", "users", "hanzo", "boss", false},
|
||||
{"regular list org", regular, "GET", "users", "hanzo", "", false},
|
||||
{"regular write own (self-promote)", regular, "POST", "users", "hanzo", "alice", false},
|
||||
{"regular read own non-user entity", regular, "GET", "roles", "hanzo", "alice", false},
|
||||
{"regular read foreign org self-name", regular, "GET", "users", "orgb", "alice", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
|
||||
t.Fatalf("authorize(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SuperAdmin is exactly org=="admin"; built-in is NOT super — the built-in gap
|
||||
// the poisoning gate must close depends on this.
|
||||
func TestSuperIsAdminOrgOnly(t *testing.T) {
|
||||
if (&Principal{Org: "built-in", Super: false}).Super {
|
||||
t.Fatal("built-in must not be SuperAdmin")
|
||||
}
|
||||
// A built-in-org principal fails the reserved-owner write even for its own org.
|
||||
if authorize(&Principal{Org: "built-in", Admin: true}, "POST", "certs", "built-in", "k") {
|
||||
t.Fatal("built-in admin must not write built-in signing certs")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
// (/mcp + /openapi gated) in authz_cases_test.go.
|
||||
|
||||
func TestEntityOf(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/v1/iam/users": "users",
|
||||
"/v1/iam/users/get": "users",
|
||||
"/v1/iam/users/update": "users",
|
||||
"/v1/iam/certs/delete": "certs",
|
||||
// Singular natives fold to the plural the policy is written in. It read
|
||||
// "application" until that split the policy: the legacy verb folded to
|
||||
// "applications" and matched the app self-read clause, while this native
|
||||
// route stayed singular, matched nothing, and 403'd the same caller.
|
||||
"/v1/iam/application": "applications",
|
||||
"/v1/iam/audit-logs": "audit-logs",
|
||||
"/mcp": "",
|
||||
"/healthz": "",
|
||||
"/v1/iam/": "",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := entityOf(path); got != want {
|
||||
t.Errorf("entityOf(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
// Read-path authorization, driven through the REAL registered router. A status code
|
||||
// is not the contract here — the BODY is: a listing that returns 200 while
|
||||
// carrying the admin signing key is a total compromise. Every case asserts on
|
||||
// what actually crossed the network.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
// doBody is do() plus the response body — the read surface's real contract.
|
||||
func (h *harness) doBody(t *testing.T, method, path, bearer string, body any) (int, string) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// leaks reports whether a response body carries private key material.
|
||||
func leaks(body string) bool {
|
||||
return strings.Contains(body, "PRIVATE KEY") || strings.Contains(body, `"privateKey":"-`)
|
||||
}
|
||||
|
||||
// TestCertPrivateKeyNeverLeaks is the PoC that proved a full token-forgery
|
||||
// compromise: a hanzo org admin listed certs and received the admin trust
|
||||
// anchor's private key. Two independent defects composed into it — the listing
|
||||
// ignored its owner (a GET binds no query, so in.Owner was always "", and an
|
||||
// empty owner listed EVERY tenant), and the response serialized privateKey. Both
|
||||
// are closed: the owner is resolved from the verified bearer (authz.Scope), and
|
||||
// a Cert is masked on the way out (schema.Cert.Mask), so the key material that
|
||||
// signs every token cannot cross the API at all — a relying party reads the
|
||||
// PUBLIC half from the JWKS (RFC 7517).
|
||||
func TestCertPrivateKeyNeverLeaks(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
anchor := rsaKeyToPEM(t, h.key) // the admin signing cert's real private key
|
||||
|
||||
t.Run("the org-admin PoC leaks neither key material nor another tenant's cert", func(t *testing.T) {
|
||||
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=hanzo", h.token(t, "hanzo/boss"), nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("own-org listing must succeed, got %d: %s", status, body)
|
||||
}
|
||||
if strings.Contains(body, anchor) || leaks(body) {
|
||||
t.Fatal("LEAK: admin signing key material in an org-admin listing")
|
||||
}
|
||||
if strings.Contains(body, signingKid) {
|
||||
t.Fatal("CROSS-TENANT: the admin-owned cert appeared in a hanzo listing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a query owner cannot widen the listing past the bearer", func(t *testing.T) {
|
||||
// Ask for the admin org explicitly: the guard denies the cross-tenant
|
||||
// read, and even if it did not, Scope binds the listing to hanzo.
|
||||
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=admin", h.token(t, "hanzo/boss"), nil)
|
||||
if status == 200 && (strings.Contains(body, signingKid) || leaks(body)) {
|
||||
t.Fatalf("LEAK: querying owner=admin escaped the bearer's scope: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SuperAdmin reads every tenant but never key material", func(t *testing.T) {
|
||||
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "admin/root"), nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("SuperAdmin listing must succeed, got %d: %s", status, body)
|
||||
}
|
||||
if !strings.Contains(body, signingKid) {
|
||||
t.Fatalf("SuperAdmin must still SEE the cert (masked, not hidden): %s", body)
|
||||
}
|
||||
if strings.Contains(body, anchor) || leaks(body) {
|
||||
t.Fatal("LEAK: key material served to SuperAdmin — the key never leaves the store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unscoped listing by a tenant is refused, never lists-all", func(t *testing.T) {
|
||||
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "hanzo/boss"), nil)
|
||||
if status == 200 && strings.Contains(body, signingKid) {
|
||||
t.Fatalf("LEAK: an empty owner listed every tenant: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the JWKS still publishes the PUBLIC half at both paths", func(t *testing.T) {
|
||||
// The keys are masked out of the CRUD surface, not out of the protocol:
|
||||
// the gateway defaults to the root path, the SDK reads the /v1/iam one.
|
||||
for _, p := range []string{"/.well-known/jwks", "/v1/iam/.well-known/jwks"} {
|
||||
status, body := h.doBody(t, "GET", p, "", nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("%s must be public and serve keys, got %d", p, status)
|
||||
}
|
||||
if !strings.Contains(body, `"kty":"RSA"`) || !strings.Contains(body, signingKid) {
|
||||
t.Fatalf("%s must publish the signing key: %s", p, body)
|
||||
}
|
||||
if leaks(body) || strings.Contains(body, `"d":`) {
|
||||
t.Fatalf("LEAK: %s served private material: %s", p, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
|
||||
// + controllers/app_mutation_guard.go requireAppCapability) that revoked the
|
||||
// "every client credential is a global admin" privilege.
|
||||
//
|
||||
// A Cap is a named authority an app principal holds ONLY when its application
|
||||
// name is listed in the allowlist Env names. It is the ONLY thing an app
|
||||
// principal's authority is made of: an app is never a SuperAdmin and never an
|
||||
// org admin (see Principal), so a leaked client credential grants exactly the
|
||||
// capabilities its NAME was allowlisted for and nothing more.
|
||||
//
|
||||
// The key is the application NAME, not its (owner, name) row. That alone would let
|
||||
// ANY owner's app claim a listed name, so Allowed ALSO pins the app's OWNING org to
|
||||
// a reserved platform signing owner (store.IsSigningCertOwner): the name is thereby
|
||||
// reserved to the platform's admin-owned app, and a tenant that registers
|
||||
// <theirOrg>/hanzo-console — same name, its own owner — inherits none of its grants.
|
||||
// The pin is what ENFORCES that reservation; the name match alone was the escalation.
|
||||
|
||||
// Cap is one capability: a Name for diagnostics and the Env var holding its
|
||||
// comma-separated allowlist of application names.
|
||||
type Cap struct {
|
||||
Name string
|
||||
Env string
|
||||
}
|
||||
|
||||
// The capability set, matching the live allowlists byte-for-byte
|
||||
// (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset
|
||||
// or empty allowlist denies EVERY app.
|
||||
var (
|
||||
// CapKeyMint gates minting, rotating, or revoking a credential on another
|
||||
// principal's behalf — the service-account administration boundary, since a
|
||||
// minted key is an org-billing credential.
|
||||
CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}
|
||||
|
||||
// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email,
|
||||
// type, credentials) — cloud moves an onboarding user into the org it just
|
||||
// created through this.
|
||||
CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}
|
||||
|
||||
// CapOrgAdmin gates organization create/read/update/delete. Unlike the
|
||||
// signing-material capabilities this one is populated in every environment:
|
||||
// the brand consoles legitimately create customer orgs during onboarding.
|
||||
CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}
|
||||
|
||||
// CapServiceAccountRead gates LISTING an org's service accounts — names and
|
||||
// metadata only, never secrets. It is the read-only counterpart to
|
||||
// CapKeyMint (a read cap can never mint, rotate, or delete a credential) and
|
||||
// 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 (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
|
||||
// write-only, and its own narrower CapPublishableResolve turns it into an org, never
|
||||
// a principal. The intended sole holder is the cloud
|
||||
// identity boundary (SanitizeIdentity), which turns a keyed request into the same
|
||||
// principal a JWT yields. Fail-secure exactly like the others: an unset or empty
|
||||
// allowlist lets NO app resolve a key. Enforced additionally as app-only at the
|
||||
// handler (a human, even a SuperAdmin, holds a capability vacuously — so the key
|
||||
// path also requires p.App != "" to keep this a service-only door).
|
||||
//
|
||||
// Keyed on the application NAME (via Allowed → p.App), matching all four sibling
|
||||
// Caps above — the ONE way capabilities are matched in this family. RED F3 asked
|
||||
// whether it should key on clientId like the issuetoken mint verbs (appInList);
|
||||
// deliberately NOT, because (1) that is a DIFFERENT, older mechanism, so making
|
||||
// CapKeyResolve clientId-based would make it the sole clientId-keyed Cap —
|
||||
// inconsistent with its own family — and (2) it would require adding ClientId to
|
||||
// the Principal shape. The owner-pin (Allowed requires AppOwner ∈ signing owners)
|
||||
// already defeats the name-collision vector: a tenant app that reuses a listed
|
||||
// name is not a signing owner and holds nothing. Under the <org>-<app> convention
|
||||
// name == clientId, so the two are equivalent in practice. Gate unchanged.
|
||||
CapKeyResolve = Cap{Name: "key-resolve", Env: "IAM_KEY_RESOLVE_APPS"}
|
||||
|
||||
// CapPublishableResolve gates resolving a WRITE-ONLY publishable pk- to just the
|
||||
// ORG that holds it (keys.resolve → /v1/iam/resolve-key), for cloud's ingest
|
||||
// boundary. It is strictly NARROWER than CapKeyResolve and deliberately a separate
|
||||
// authority: this door discloses only an org (a pk- is public, shipped in client
|
||||
// JS), NEVER a principal, so the two must not be conflated — a client granted the
|
||||
// org-resolve capability must never thereby be able to disclose WHO a secret key
|
||||
// authenticates. Fail-secure exactly like the others: an unset or empty allowlist
|
||||
// lets NO app resolve a publishable key. Keyed on the application NAME (via
|
||||
// Allowed → p.App), like every sibling Cap, with the same owner-pin (Allowed
|
||||
// requires AppOwner ∈ reserved signing owners), so a tenant app that reuses a
|
||||
// listed name inherits nothing.
|
||||
CapPublishableResolve = Cap{Name: "publishable-resolve", Env: "IAM_PUBLISHABLE_RESOLVE_APPS"}
|
||||
)
|
||||
|
||||
// Allowed reports whether p holds c.
|
||||
//
|
||||
// A non-app principal holds every capability vacuously: this gate concerns
|
||||
// confidential clients ONLY, and a human's authority is decided by the org
|
||||
// policy in authorize(). Conflating the two would either lock every human out or
|
||||
// hand every app a human's scope.
|
||||
//
|
||||
// Fail-secure, exactly as v1: an app whose allowlist is unset, empty, or does
|
||||
// not name it holds nothing.
|
||||
func Allowed(p *Principal, c Cap) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p.App == "" {
|
||||
return true // not an app; the org policy decides
|
||||
}
|
||||
// The owner-pin: an app holds a platform capability ONLY when its OWNING org is a
|
||||
// reserved platform signing owner (admin/built-in). Every allow-listed console is
|
||||
// admin-owned, so this never revokes a legitimate grant — but it binds the NAME
|
||||
// allowlist to the platform: a tenant that registers <theirOrg>/hanzo-console
|
||||
// (same name, its OWN owner) is not a signing owner, so it inherits nothing. This
|
||||
// is the single gate that turns the allowlist's NAME key from a spoofable label
|
||||
// into an authority reserved to the admin-owned app.
|
||||
if !store.IsSigningCertOwner(p.AppOwner) {
|
||||
return false
|
||||
}
|
||||
if c.Env == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Split(os.Getenv(c.Env), ",") {
|
||||
if strings.TrimSpace(item) == p.App {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoundToOrg reports whether an app principal is bound to org by the
|
||||
// <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo
|
||||
// and on no other tenant's. The org is derived from the (allowlist-reserved)
|
||||
// application NAME, so the binding holds regardless of the app row's owner, and
|
||||
// it is the same prefix rule the service-account names it reads obey.
|
||||
func BoundToOrg(p *Principal, org string) bool {
|
||||
if p == nil || org == "" {
|
||||
return false
|
||||
}
|
||||
prefix := org + "-"
|
||||
return len(p.App) > len(prefix) && strings.HasPrefix(p.App, prefix)
|
||||
}
|
||||
|
||||
// capFor maps an entity to the capability a confidential client needs to act on
|
||||
// it. An entity with NO mapping grants an app nothing: unmapped denies exactly
|
||||
// as an unset allowlist does, which IS v1's live behaviour for every capability
|
||||
// the deployment leaves empty — certs, providers, tokens, syncers, webhooks are
|
||||
// all deny-all by design, because no client credential should ever reach signing
|
||||
// material. Only the two entities a live confidential client touches are mapped:
|
||||
// the brand consoles create customer orgs, and cloud moves the onboarding user
|
||||
// into the org it just created.
|
||||
func capFor(entity string) Cap {
|
||||
switch entity {
|
||||
case "organizations":
|
||||
return CapOrgAdmin
|
||||
case "users":
|
||||
return CapUserAdmin
|
||||
case "keys":
|
||||
// The same authority that already mints, rotates and revokes a user's
|
||||
// credential on its behalf (CapKeyMint) also READS the key set it manages —
|
||||
// a strictly smaller disclosure than the mint it is already trusted with,
|
||||
// and safe on its own now that every key read is masked (schema.Key.Mask
|
||||
// blanks the confidential sk- half). Without this, the ONE key list in the
|
||||
// system was reachable by SuperAdmin alone, so the surface a user calls to
|
||||
// see their own keys had no truthful read at all and reported "no key"
|
||||
// immediately after a successful mint.
|
||||
return CapKeyMint
|
||||
}
|
||||
return Cap{}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// entityNoun is the fold that makes both surfaces name the same entity. Pin it
|
||||
// directly: this is the mapping the whole compat authorization surface rides on.
|
||||
func TestEntityNoun_FoldsVerbSpellingOntoTheEntity(t *testing.T) {
|
||||
for seg, want := range map[string]string{
|
||||
"get-application": "applications",
|
||||
"add-organization": "organizations",
|
||||
"update-user": "users",
|
||||
"delete-membership": "memberships", // already plural, left alone
|
||||
"get-cert": "certs",
|
||||
"get-users": "users",
|
||||
"applications": "applications", // native noun, unchanged
|
||||
"certs": "certs",
|
||||
"organizations": "organizations",
|
||||
"get-": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := entityNoun(seg); got != want {
|
||||
t.Errorf("entityNoun(%q) = %q, want %q", seg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every key route must name the SAME entity, so a capability keyed on it is live on
|
||||
// all of them. The keys package used to serve its list at /v1/iam/keys and every other
|
||||
// op at /v1/iam/key, which entityOf reads as two different entities ("keys" and
|
||||
// "key") — so any capability granted for keys was dead on whichever half you did not
|
||||
// name. This is the same defect entityNoun fixes for the legacy verb spellings,
|
||||
// arrived at from the other direction: an inconsistent NOUN rather than a verb.
|
||||
func TestEntityOf_EveryKeyRouteNamesOneEntity(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/iam/keys",
|
||||
"/v1/iam/keys/get",
|
||||
"/v1/iam/keys/update",
|
||||
"/v1/iam/keys/delete",
|
||||
} {
|
||||
if got := entityOf(path); got != "keys" {
|
||||
t.Errorf("entityOf(%q) = %q, want \"keys\" — a capability keyed on keys is dead on this path", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The read that makes a user's own key list truthful: the confidential client already
|
||||
// trusted to MINT, ROTATE and REVOKE a user's credential may also READ the key set it
|
||||
// manages. Strictly less disclosure than the mint it already holds, and safe on its
|
||||
// own because every key read is masked (schema.Key.Mask blanks the sk- half).
|
||||
func TestCapFor_KeysMapsToTheMintCapability(t *testing.T) {
|
||||
if capFor("keys") != CapKeyMint {
|
||||
t.Fatalf("capFor(\"keys\") = %+v, want CapKeyMint — without it the ONE key list is SuperAdmin-only and a user cannot see their own keys", capFor("keys"))
|
||||
}
|
||||
// Still fail-secure: holding it requires being ON the allow-list, under a reserved
|
||||
// signing owner. The capability is a grant to a named platform app, not to apps.
|
||||
t.Setenv(CapKeyMint.Env, "hanzo-console")
|
||||
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, capFor("keys")) {
|
||||
t.Fatal("an allow-listed, admin-owned minter must be able to read the keys it manages")
|
||||
}
|
||||
if Allowed(&Principal{App: "hanzo-console", AppOwner: "acme"}, capFor("keys")) {
|
||||
t.Fatal("the owner-pin must deny a tenant app that reuses an allow-listed name")
|
||||
}
|
||||
if Allowed(&Principal{App: "other-app", AppOwner: "admin"}, capFor("keys")) {
|
||||
t.Fatal("an app that is not on the allow-list must hold nothing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// The "<org>-platform-kms" machine identity may READ its own org's projects —
|
||||
// the grant that lets cloud's platform resolve a tenant's projects from THIS
|
||||
// store instead of a second embedded database. Each wall of the grant gets its
|
||||
// own negative: wrong method, wrong entity, wrong org, wrong identity.
|
||||
func TestAuthorize_KMSMachineReadsOwnProjects(t *testing.T) {
|
||||
acme := &Principal{Org: "acme", App: "acme-platform-kms", AppOwner: "admin"}
|
||||
|
||||
if !authorize(acme, "GET", "projects", "acme", "web") {
|
||||
t.Fatal("the org's own platform-kms identity must read the org's projects")
|
||||
}
|
||||
if !authorize(acme, "GET", "projects", "acme", "") {
|
||||
t.Fatal("listing the org's projects is the same read")
|
||||
}
|
||||
|
||||
// Wrong ORG: one tenant's identity can never walk another's list.
|
||||
if authorize(acme, "GET", "projects", "rival", "web") {
|
||||
t.Fatal("cross-org project read must be refused")
|
||||
}
|
||||
// Wrong METHOD: the grant is a read, never a write.
|
||||
for _, m := range []string{"POST", "PUT", "PATCH", "DELETE"} {
|
||||
if authorize(acme, m, "projects", "acme", "web") {
|
||||
t.Fatalf("%s on projects must be refused — the grant is read-only", m)
|
||||
}
|
||||
}
|
||||
// Wrong ENTITY: projects and nothing else.
|
||||
for _, e := range []string{"users", "organizations", "applications", "providers"} {
|
||||
if authorize(acme, "GET", e, "acme", "x") {
|
||||
t.Fatalf("the grant must not widen to %s", e)
|
||||
}
|
||||
}
|
||||
// Wrong IDENTITY: only the contract-named app. A sibling app in the same
|
||||
// org, and another org's platform-kms name, both stay refused.
|
||||
for _, app := range []string{"acme-console", "rival-platform-kms", "platform-kms"} {
|
||||
p := &Principal{Org: "acme", App: app, AppOwner: "admin"}
|
||||
if authorize(p, "GET", "projects", "acme", "web") {
|
||||
t.Fatalf("app %q must not inherit the platform-kms grant", app)
|
||||
}
|
||||
}
|
||||
// An empty owner target is not admitted: the read must NAME the org so the
|
||||
// owner==p.Org pin has something to hold.
|
||||
if authorize(acme, "GET", "projects", "", "web") {
|
||||
t.Fatal("an owner-less project read must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
// AN ORG-SCOPED REQUEST IS HONOURED OR REFUSED, NEVER SILENTLY REINTERPRETED.
|
||||
//
|
||||
// The defect these tests pin down, reproduced twice against production on
|
||||
// 2026-07-28 with the hanzo-console client credential (home org `hanzo`):
|
||||
//
|
||||
// GET /v1/iam/get-users?owner=hanzo -> 200 ok, 262 records, owner=hanzo
|
||||
// GET /v1/iam/get-users?owner=lux -> 200 ok, 262 records, owner=hanzo
|
||||
// GET /v1/iam/get-users?owner=nonexistent-xyz -> 200 ok, 262 records, owner=hanzo
|
||||
//
|
||||
// Nothing in the status code, the `status` field, the message or the count says
|
||||
// the filter was dropped, so a FABRICATED org is indistinguishable from a real
|
||||
// one AND from the caller's own. That is not a confidentiality breach — no
|
||||
// tenant's rows escape — it is MISATTRIBUTION, which is worse in one specific
|
||||
// way: the caller believes it holds tenant B while holding tenant A. It nearly
|
||||
// caused a production purge of the wrong tenant: an operator asked for
|
||||
// owner=lux, received 262 hanzo accounts, and every surface signal read success.
|
||||
//
|
||||
// A status code is therefore NOT the contract here. Every case asserts on the
|
||||
// RECORDS that crossed the wire, because "200 with somebody else's rows" is the
|
||||
// exact failure being closed.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
// The two spellings an unauthorized caller must not be able to tell apart: a
|
||||
// FOREIGN-BUT-REAL tenant, and a name no tenant has ever had. If these two
|
||||
// answers differ in any byte, the refusal is an org-existence oracle.
|
||||
const (
|
||||
foreignRealOrg = "lux"
|
||||
fabricatedOrg = "nonexistent-org-xyz"
|
||||
)
|
||||
|
||||
// ---- request helpers -------------------------------------------------------
|
||||
|
||||
// reply is one response reduced to what a client can actually observe.
|
||||
type reply struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
// records decodes the v1 envelope's `data` array into (owner, name) pairs — the
|
||||
// rows that actually crossed the wire.
|
||||
func (r reply) records(t *testing.T) []schema.User {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
Data []schema.User `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r.body), &env); err != nil {
|
||||
return nil // an error envelope carries no array; zero records is the point
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
// owners returns the DISTINCT owners present in a listing — the misattribution
|
||||
// assertion: a request for org X must never answer with rows owned by Y.
|
||||
func (r reply) owners(t *testing.T) map[string]int {
|
||||
t.Helper()
|
||||
got := map[string]int{}
|
||||
for _, u := range r.records(t) {
|
||||
got[u.Owner]++
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// send issues one request through the REAL registered router and returns
|
||||
// everything a client sees. auth is applied verbatim as the Authorization value.
|
||||
func (h *harness) send(t *testing.T, method, path, auth string, body any) reply {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return reply{status: resp.StatusCode, body: string(b)}
|
||||
}
|
||||
|
||||
// asApp is the client_secret_basic header a confidential client sends — the
|
||||
// exact transport the hanzo-console credential used in the production repro.
|
||||
func asApp(clientID, secret string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret))
|
||||
}
|
||||
|
||||
// asUser is the bearer header a human carries.
|
||||
func asUser(tok string) string { return "Bearer " + tok }
|
||||
|
||||
// ---- fixtures --------------------------------------------------------------
|
||||
|
||||
// seedScopeFixture builds the production shape: a foreign-but-real tenant `lux`
|
||||
// with its own users and projects alongside hanzo's, plus the admin-owned
|
||||
// hanzo-console application whose capability allowlist admits it to the users
|
||||
// entity. That capability is what carries the request PAST the Guard and into
|
||||
// authz.Scope — without it the Guard refuses first and the silent discard is
|
||||
// never reached, which is why a unit test on authorize() alone proves nothing
|
||||
// here.
|
||||
func seedScopeFixture(t *testing.T, h *harness) {
|
||||
t.Helper()
|
||||
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
|
||||
|
||||
seedAppRow(t, h.db, "admin", "hanzo-console", "s3cret", signingKid)
|
||||
|
||||
// The foreign-but-real tenant. Its org row exists, its users exist — so a
|
||||
// refusal that consulted the store COULD tell it apart from a fabrication.
|
||||
seedOrgRow(t, h.db, foreignRealOrg)
|
||||
seedUser(t, h.db, foreignRealOrg, "lux-alice", true, false, false)
|
||||
seedUser(t, h.db, foreignRealOrg, "lux-bob", false, false, false)
|
||||
seedProjectRow(t, h.db, foreignRealOrg, "lux-secret-project")
|
||||
|
||||
seedOrgRow(t, h.db, "hanzo")
|
||||
seedProjectRow(t, h.db, "hanzo", "hanzo-project")
|
||||
}
|
||||
|
||||
// seedOrgRow registers a tenant in the org registry, which is admin-owned: the
|
||||
// org's identity is its NAME, not its owner.
|
||||
func seedOrgRow(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", name
|
||||
o.SetId("admin/" + name)
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed org %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedProjectRow adds one project under a tenant.
|
||||
func seedProjectRow(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
p := orm.New[schema.Project](db)
|
||||
p.Owner, p.Name = owner, name
|
||||
p.SetId(owner + "/" + name)
|
||||
if err := p.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed project %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bug ---------------------------------------------------------------
|
||||
|
||||
// THE PRODUCTION REPRO. A non-super principal asking for an org that is not its
|
||||
// own must be REFUSED — not answered with its own org's rows under the foreign
|
||||
// org's name.
|
||||
func TestScope_ForeignOrgIsRefusedNotSilentlyReinterpreted(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
auth := asApp("hanzo-console", "s3cret")
|
||||
|
||||
// Own org: unchanged, and it is what makes the foreign case meaningful —
|
||||
// there ARE hanzo rows to be misattributed.
|
||||
own := h.send(t, "GET", "/v1/iam/get-users?owner=hanzo", auth, nil)
|
||||
if own.status != 200 {
|
||||
t.Fatalf("own-org listing = %d, want 200 (unchanged): %s", own.status, own.body)
|
||||
}
|
||||
if len(own.records(t)) == 0 {
|
||||
t.Fatal("own-org listing returned no rows; the fixture cannot prove misattribution")
|
||||
}
|
||||
|
||||
for _, org := range []string{foreignRealOrg, fabricatedOrg} {
|
||||
t.Run(org, func(t *testing.T) {
|
||||
got := h.send(t, "GET", "/v1/iam/get-users?owner="+org, auth, nil)
|
||||
|
||||
// (1) The refusal must be EXPLICIT.
|
||||
if got.status != 403 {
|
||||
t.Errorf("GET get-users?owner=%s = %d, want 403 — an org-scoped request "+
|
||||
"is honoured or refused, never silently reinterpreted: %s",
|
||||
org, got.status, got.body)
|
||||
}
|
||||
// (2) And it must carry NOTHING. A 403 that still ships rows, or a
|
||||
// 200 carrying the caller's own rows under another org's name, is
|
||||
// the misattribution this closes.
|
||||
if owners := got.owners(t); len(owners) > 0 {
|
||||
t.Errorf("GET get-users?owner=%s returned rows owned by %v — the caller "+
|
||||
"asked for %s and was handed somebody else's tenant", org, owners, org)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal must not become an ORG-EXISTENCE ORACLE. A foreign-but-real tenant
|
||||
// and a name no tenant has ever had must be answered IDENTICALLY, byte for byte,
|
||||
// or an unauthorized caller enumerates the customer list one guess at a time.
|
||||
//
|
||||
// The property is structural, not cosmetic: the decision is taken from the
|
||||
// verified principal alone and never touches the store, so there is no lookup
|
||||
// whose outcome could differ. This test is what keeps it that way.
|
||||
func TestScope_ForeignAndFabricatedOrgsAreIndistinguishable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
auth := asApp("hanzo-console", "s3cret")
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/iam/get-users?owner=",
|
||||
"/v1/iam/get-organizations?owner=",
|
||||
"/v1/iam/get-organization-projects?organization=",
|
||||
"/v1/iam/scim/v2/Users?owner=",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
real := h.send(t, "GET", path+foreignRealOrg, auth, nil)
|
||||
fake := h.send(t, "GET", path+fabricatedOrg, auth, nil)
|
||||
if real.status != fake.status {
|
||||
t.Errorf("%s: real org -> %d, fabricated org -> %d: the STATUS distinguishes "+
|
||||
"a tenant that exists from one that does not", path, real.status, fake.status)
|
||||
}
|
||||
if real.body != fake.body {
|
||||
t.Errorf("%s: the BODY distinguishes a real tenant from a fabricated one\n"+
|
||||
" real (%s): %s\n fake (%s): %s",
|
||||
path, foreignRealOrg, real.body, fabricatedOrg, fake.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A SuperAdmin's cross-tenant reach is the ONE cross-tenant scope and is
|
||||
// unchanged: it asks for lux and it gets LUX, not hanzo.
|
||||
func TestScope_SuperAdminCrossOrgReadIsUnchanged(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
root := asUser(h.token(t, "admin/root"))
|
||||
|
||||
got := h.send(t, "GET", "/v1/iam/get-users?owner="+foreignRealOrg, root, nil)
|
||||
if got.status != 200 {
|
||||
t.Fatalf("SuperAdmin cross-org listing = %d, want 200: %s", got.status, got.body)
|
||||
}
|
||||
owners := got.owners(t)
|
||||
if owners[foreignRealOrg] == 0 {
|
||||
t.Errorf("SuperAdmin asked for %s and got %v — cross-tenant reach regressed",
|
||||
foreignRealOrg, owners)
|
||||
}
|
||||
if len(owners) != 1 {
|
||||
t.Errorf("SuperAdmin asked for %s and got rows from %v — the owner filter was dropped",
|
||||
foreignRealOrg, owners)
|
||||
}
|
||||
}
|
||||
|
||||
// Own-org access is untouched for a HUMAN too, on the endpoints the Guard does
|
||||
// not pre-authorize (their target rides in ?organization=, so authz.Scope is the
|
||||
// only gate they have).
|
||||
func TestScope_OwnOrgReadIsUnchangedForAHuman(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
boss := asUser(h.token(t, "hanzo/boss"))
|
||||
|
||||
got := h.send(t, "GET", "/v1/iam/get-organization-projects?organization=hanzo", boss, nil)
|
||||
if got.status != 200 {
|
||||
t.Fatalf("own-org project list = %d, want 200 (unchanged): %s", got.status, got.body)
|
||||
}
|
||||
var env struct {
|
||||
Data []schema.Project `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got.body), &env); err != nil {
|
||||
t.Fatalf("decode %s: %v", got.body, err)
|
||||
}
|
||||
if len(env.Data) == 0 {
|
||||
t.Errorf("own-org project list came back empty: %s", got.body)
|
||||
}
|
||||
}
|
||||
|
||||
// The SAME silent discard, reachable by an ORDINARY HUMAN — no client credential
|
||||
// needed. get-organization-projects and get-organization-workspaces are
|
||||
// handler-authorized (their target rides in ?organization=, which the Guard does
|
||||
// not inspect), so authz.Scope is the whole gate, and it rewrote the parameter.
|
||||
// An org admin asking for lux's projects got HANZO's, labelled lux.
|
||||
func TestScope_HandlerAuthorizedReadsAreNotSilentlyRewritten(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
boss := asUser(h.token(t, "hanzo/boss"))
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/iam/get-organization-projects?organization=" + foreignRealOrg,
|
||||
"/v1/iam/get-organization-workspaces?organization=" + foreignRealOrg,
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
got := h.send(t, "GET", path, boss, nil)
|
||||
if got.status != 403 {
|
||||
t.Errorf("GET %s = %d, want 403: %s", path, got.status, got.body)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(got.body), &env)
|
||||
for _, row := range env.Data {
|
||||
t.Errorf("GET %s returned a row owned by %v — a hanzo row answering a "+
|
||||
"request for %s is the misattribution, not a leak", path, row["owner"], foreignRealOrg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE WORST SHAPE OF THE BUG: a path-targeted SCIM read. `/Users/lux/alice`
|
||||
// named a specific row in a specific tenant; Scope rewrote the owner half and
|
||||
// the handler answered with hanzo/alice — a DIFFERENT HUMAN, under the requested
|
||||
// identity's URL. A caller that then acts on that record acts on the wrong
|
||||
// person in the wrong tenant.
|
||||
func TestScope_SCIMPathTargetIsNeverRewrittenToAnotherTenant(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
boss := asUser(h.token(t, "hanzo/boss"))
|
||||
|
||||
got := h.send(t, "GET", "/v1/iam/scim/v2/Users/"+foreignRealOrg+"/alice", boss, nil)
|
||||
if got.status == 200 {
|
||||
t.Errorf("GET /Users/%s/alice = 200 — it resolved SOMEBODY, and hanzo/alice is "+
|
||||
"the only alice there is: %s", foreignRealOrg, got.body)
|
||||
}
|
||||
if got.status != 403 {
|
||||
t.Errorf("GET /Users/%s/alice = %d, want 403: %s", foreignRealOrg, got.status, got.body)
|
||||
}
|
||||
}
|
||||
|
||||
// A WRITE misattribution is worse than a read one: a SCIM provisioning call that
|
||||
// named tenant `lux` created the account inside `hanzo`. Assert the refusal AND
|
||||
// that nothing was persisted anywhere — the real security property, not the
|
||||
// status code.
|
||||
func TestScope_SCIMProvisioningNeverLandsInTheWrongTenant(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
boss := asUser(h.token(t, "hanzo/boss"))
|
||||
|
||||
body := map[string]any{
|
||||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
|
||||
"userName": "misfiled",
|
||||
"urn:ietf:params:scim:schemas:extension:hanzo:2.0:User": map[string]any{
|
||||
"owner": foreignRealOrg,
|
||||
},
|
||||
}
|
||||
got := h.send(t, "POST", "/v1/iam/scim/v2/Users", boss, body)
|
||||
if got.status == 201 {
|
||||
t.Errorf("SCIM create naming owner=%s succeeded (%d): %s", foreignRealOrg, got.status, got.body)
|
||||
}
|
||||
if h.userExists(t, "hanzo", "misfiled") {
|
||||
t.Errorf("a create that NAMED tenant %s persisted the account under hanzo — "+
|
||||
"the caller believes it provisioned %s", foreignRealOrg, foreignRealOrg)
|
||||
}
|
||||
if h.userExists(t, foreignRealOrg, "misfiled") {
|
||||
t.Errorf("a hanzo admin provisioned an account inside %s", foreignRealOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// get-users AND get-organization must give the SAME answer to "may this
|
||||
// principal see another tenant's org?" — for every principal that holds no
|
||||
// cross-tenant grant. A human org-admin is the case that carries a secret, and on
|
||||
// both verbs a foreign-but-real org and a fabricated one are the identical
|
||||
// existence-independent refusal. Answering one of them differently would make the
|
||||
// pair an org-existence oracle no matter how carefully the other was written.
|
||||
func TestScope_GetUsersAndGetOrganizationAgreeForAnUngrantedPrincipal(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
boss := asUser(h.token(t, "hanzo/boss")) // org-admin of hanzo, no capability, not super
|
||||
|
||||
for _, verb := range []struct{ name, pattern string }{
|
||||
{"get-users", "/v1/iam/get-users?owner=%s"},
|
||||
{"get-organization", "/v1/iam/get-organization?id=admin%%2F%s"},
|
||||
} {
|
||||
t.Run(verb.name, func(t *testing.T) {
|
||||
real := h.send(t, "GET", fmt.Sprintf(verb.pattern, foreignRealOrg), boss, nil)
|
||||
fake := h.send(t, "GET", fmt.Sprintf(verb.pattern, fabricatedOrg), boss, nil)
|
||||
if real.status == 200 || fake.status == 200 {
|
||||
t.Errorf("%s admitted a foreign org: real=%d fake=%d", verb.name, real.status, fake.status)
|
||||
}
|
||||
if real.status != fake.status || real.body != fake.body {
|
||||
t.Errorf("%s distinguishes a real tenant from a fabricated one — that pair IS "+
|
||||
"the org-existence oracle\n real: %d %s\n fake: %d %s",
|
||||
verb.name, real.status, real.body, fake.status, fake.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the coherent policy: where a cross-tenant grant DOES exist,
|
||||
// it HONOURS the org the request names. CapOrgAdmin is the brand consoles'
|
||||
// registry authority — they create customer orgs during onboarding and read
|
||||
// Organization.Founder to resume a partial one — so a grant holder asking for lux
|
||||
// gets LUX's row. Correctly attributed is the whole requirement; substituting
|
||||
// hanzo's row here would be the same misattribution wearing a capability.
|
||||
func TestScope_AGrantHonoursTheOrgItNamesAndNeverSubstitutes(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
|
||||
got := h.send(t, "GET", "/v1/iam/get-organization?id=admin%2F"+foreignRealOrg,
|
||||
asApp("hanzo-console", "s3cret"), nil)
|
||||
if got.status != 200 {
|
||||
t.Fatalf("CapOrgAdmin registry read = %d, want 200 — onboarding reads Founder "+
|
||||
"through this: %s", got.status, got.body)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got.body), &env); err != nil {
|
||||
t.Fatalf("decode %s: %v", got.body, err)
|
||||
}
|
||||
if env.Data.Name != foreignRealOrg {
|
||||
t.Errorf("asked for org %q, got %q — a grant must return the org it was asked for, "+
|
||||
"never another", foreignRealOrg, env.Data.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// An UNSTATED scope is not a reinterpreted one. Omitting ?owner= has always
|
||||
// meant "my own org" and still does — the rule is about a request that NAMES an
|
||||
// org it may not have, not about one that names none.
|
||||
func TestScope_UnstatedOwnerStillMeansOwnOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedScopeFixture(t, h)
|
||||
|
||||
got := h.send(t, "GET", "/v1/iam/get-users", asApp("hanzo-console", "s3cret"), nil)
|
||||
if got.status != 200 {
|
||||
t.Fatalf("get-users with no owner = %d, want 200 (unchanged): %s", got.status, got.body)
|
||||
}
|
||||
owners := got.owners(t)
|
||||
if owners["hanzo"] == 0 || len(owners) != 1 {
|
||||
t.Errorf("get-users with no owner returned %v, want hanzo only", owners)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
// THE REQUEST CLOUD ACTUALLY MAKES.
|
||||
//
|
||||
// The first cut of the self-read grant was unit-tested against authorize() with
|
||||
// entity "applications" and passed — while production still 403'd, because the
|
||||
// live caller uses the the legacy surface alias /v1/iam/get-application and entityOf resolved
|
||||
// that to the literal "get-application", which matched no clause. A test written
|
||||
// against the noun surface proves nothing about the verb surface, exactly like the
|
||||
// login tests that post authorize params in the body no real client uses.
|
||||
//
|
||||
// So every case here goes through the REAL router, over the compat verb, with
|
||||
// client_secret_basic — the shape hanzo-cloud sends.
|
||||
|
||||
// seedAppRow registers an application the way the platform does: owned by admin,
|
||||
// holding a secret, referencing a signing cert.
|
||||
func seedAppRow(t *testing.T, db orm.DB, owner, name, secret, cert string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = owner, name
|
||||
a.ClientId, a.ClientSecret = name, secret
|
||||
a.Organization, a.Cert = "hanzo", cert
|
||||
a.SetId(owner + "/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedCertRow adds a signing cert row under an owner.
|
||||
func seedCertRow(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// basicGet issues a GET with client_secret_basic, as a confidential client does.
|
||||
func (h *harness) basicGet(t *testing.T, path, clientID, secret string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Authorization", "Basic "+
|
||||
base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// A relying party bootstraps: read its own application, then the cert that
|
||||
// application names. Both must succeed over the COMPAT VERB, or cloud panics one
|
||||
// line after the read it was granted.
|
||||
func TestSelfRead_OverTheCompatVerbCloudActuallyCalls(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
|
||||
// Production carries the SAME cert under two owners (seed drift, same keypair);
|
||||
// mirror that so the org-qualified spelling the binary sends is exercised.
|
||||
seedCertRow(t, h.db, "hanzo", signingKid)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, path string
|
||||
want int
|
||||
}{
|
||||
// The exact request, both spellings of the id the caller may send.
|
||||
{"own application, owner-qualified", "/v1/iam/get-application?id=admin%2Fhanzo-cloud", 200},
|
||||
// 200 is not enough: Scope used to rewrite the owner to the app's SERVED org,
|
||||
// so the read was authorized and then answered "the entity does not exist" —
|
||||
// a 200 that is functionally the 403 it replaced. The body is asserted below.
|
||||
{"own cert, owner-qualified", "/v1/iam/get-cert?id=admin%2F" + signingKid, 200},
|
||||
{"own cert, bare name", "/v1/iam/get-cert?id=" + signingKid, 200},
|
||||
// THE SHAPE THE BINARY SENDS. ai/internal/iam/cert.go:35 builds
|
||||
// "<IAM_ORG>/<name>", so hanzo/cert-hanzo is the only spelling that matters in
|
||||
// production; the bare form is the one I verified last time and it was not it.
|
||||
{"own cert, org-qualified (what ai sends)", "/v1/iam/get-cert?id=hanzo%2F" + signingKid, 200},
|
||||
// The native noun surface must agree — one policy, two spellings. The LIST
|
||||
// route is not the self-read: ApplicationQuery carries only Owner, so
|
||||
// ?name= is ignored and this asks to enumerate EVERY application under the
|
||||
// reserved admin org — which a tenant app may not do. 403 is the right
|
||||
// answer and the policy agreeing with itself.
|
||||
//
|
||||
// It read 400 until zip v1.17.1 taught a typed op to read the whole URL. The
|
||||
// query never bound, so validate fired on an empty Owner and the shape
|
||||
// complaint landed BEFORE authz could refuse — a 400 standing in for a 403,
|
||||
// which this case then asserted as "reaches the handler = authorized".
|
||||
{"noun surface LIST of a reserved org is refused", "/v1/iam/applications?owner=admin&name=hanzo-cloud", 403},
|
||||
// The actual self-read on the noun surface: ONE application by its natural
|
||||
// key, which is what the compat verb above expresses.
|
||||
{"own application, noun surface (single)", "/v1/iam/application?owner=admin&name=hanzo-cloud", 200},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := h.basicGet(t, tc.path, "hanzo-cloud", "s3cret"); got != tc.want {
|
||||
t.Errorf("GET %s as hanzo-cloud = %d, want %d", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The grant stays a SELF-read. Everything an app is not is still refused, over the
|
||||
// same verb surface that now resolves correctly — normalizing entityOf must not
|
||||
// have turned the compat aliases into an open door.
|
||||
func TestSelfRead_StillRefusesEverythingElse(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
|
||||
seedAppRow(t, h.db, "admin", "hanzo-console", "other", signingKid)
|
||||
seedAppRow(t, h.db, "hanzo", "hanzo-cloud-tenant", "tsecret", "cert-other")
|
||||
|
||||
// A second signing cert this app does NOT reference.
|
||||
c := orm.New[schema.Cert](h.db)
|
||||
c.Owner, c.Name = "admin", "cert-lux"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.SetId("admin/cert-lux")
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, path string }{
|
||||
{"a sibling application", "/v1/iam/get-application?id=admin%2Fhanzo-console"},
|
||||
{"same name, tenant owner", "/v1/iam/get-application?id=hanzo%2Fhanzo-cloud"},
|
||||
{"a cert it does not reference", "/v1/iam/get-cert?id=admin%2Fcert-lux"},
|
||||
{"a cert it does not reference, bare", "/v1/iam/get-cert?id=cert-lux"},
|
||||
{"the whole application list", "/v1/iam/get-applications?owner=admin"},
|
||||
{"the whole cert list", "/v1/iam/get-certs?owner=admin"},
|
||||
{"a user row", "/v1/iam/get-users?owner=admin"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := h.basicGet(t, tc.path, "hanzo-cloud", "s3cret"); got == 200 {
|
||||
t.Errorf("GET %s as hanzo-cloud was ADMITTED (200); self-read must not widen", tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A bad client secret is still not a principal at all.
|
||||
func TestSelfRead_WrongSecretIsNotAPrincipal(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
|
||||
if got := h.basicGet(t, "/v1/iam/get-application?id=admin%2Fhanzo-cloud", "hanzo-cloud", "wrong"); got == 200 {
|
||||
t.Errorf("a wrong client secret read the application row")
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 whose body says "the entity does not exist" is not a fix. Assert the row
|
||||
// actually comes back — this is the failure the first probe caught.
|
||||
func TestSelfRead_ReturnsTheRowNotAnEmptyOk(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedAppRow(t, h.db, "admin", "hanzo-cloud", "s3cret", signingKid)
|
||||
|
||||
req := httptest.NewRequest("GET", "/v1/iam/get-application?id=admin%2Fhanzo-cloud", nil)
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Authorization", "Basic "+
|
||||
base64.StdEncoding.EncodeToString([]byte("hanzo-cloud:s3cret")))
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Cert string `json:"cert"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
if env.Status != "ok" {
|
||||
t.Fatalf("self-read answered status=%q msg=%q — authorized but unable to read itself", env.Status, env.Msg)
|
||||
}
|
||||
if env.Data.Owner != "admin" || env.Data.Name != "hanzo-cloud" {
|
||||
t.Errorf("got %s/%s, want admin/hanzo-cloud", env.Data.Owner, env.Data.Name)
|
||||
}
|
||||
if env.Data.Cert != signingKid {
|
||||
t.Errorf("cert = %q, want %q — cloud reads this next", env.Data.Cert, signingKid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// SELF-READ. An app may read the row it authenticated as — the ordinary bootstrap
|
||||
// of an OIDC relying party — and nothing else. The owner-pin that closed the
|
||||
// "every client credential is a global admin" escalation was missing this one case,
|
||||
// so a confidential client could not read even itself and every cloud deploy 403'd.
|
||||
//
|
||||
// The grant is keyed on BOTH halves of (AppOwner, App), which is what keeps it a
|
||||
// self-read rather than "apps may read applications".
|
||||
func TestAuthorize_AppReadsOnlyItsOwnRecord(t *testing.T) {
|
||||
cloud := &Principal{App: "hanzo-cloud", AppOwner: "admin", Org: "hanzo"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
p *Principal
|
||||
method, owner, target string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"its own record", cloud, "GET", "admin", "hanzo-cloud", true,
|
||||
"an app must be able to bootstrap from its own registration"},
|
||||
|
||||
// The two collision directions the owner-pin exists to separate. Both were
|
||||
// 403 before this change and must STAY 403.
|
||||
{"same name, tenant owner", cloud, "GET", "hanzo", "hanzo-cloud", false,
|
||||
"a tenant-registered app of the same NAME is a different row"},
|
||||
{"sibling in the same owner", cloud, "GET", "admin", "hanzo-console", false,
|
||||
"reading a sibling would make this 'apps may read applications'"},
|
||||
{"another tenant's app", cloud, "GET", "acme", "acme-thing", false,
|
||||
"cross-tenant read"},
|
||||
|
||||
// Read only. A write to its own row would let a client widen its own
|
||||
// redirect URIs or grant types — self-escalation.
|
||||
{"write to its own record", cloud, "POST", "admin", "hanzo-cloud", false,
|
||||
"self-read must never become self-write"},
|
||||
{"delete its own record", cloud, "DELETE", "admin", "hanzo-cloud", false,
|
||||
"self-read must never become self-delete"},
|
||||
|
||||
// The grant is scoped to the applications entity alone.
|
||||
{"users under its own owner", cloud, "GET", "admin", "hanzo-cloud", false,
|
||||
"the entity is users here, not applications"},
|
||||
|
||||
// An app with no owner pin holds nothing (the fail-closed default).
|
||||
{"unpinned app", &Principal{App: "hanzo-cloud"}, "GET", "admin", "hanzo-cloud", false,
|
||||
"an app whose AppOwner is empty matches no row"},
|
||||
{"empty owner target", cloud, "GET", "", "hanzo-cloud", false,
|
||||
"an empty owner must never match"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entity := "applications"
|
||||
if tc.name == "users under its own owner" {
|
||||
entity = "users"
|
||||
}
|
||||
if got := authorize(tc.p, tc.method, entity, tc.owner, tc.target); got != tc.want {
|
||||
t.Errorf("authorize(%s %s %s/%s) = %v, want %v — %s",
|
||||
tc.method, entity, tc.owner, tc.target, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A HUMAN is unaffected by the self-read clause: their authority is still decided
|
||||
// by the org policy below it, so a tenant user cannot read a platform app row.
|
||||
func TestAuthorize_SelfReadDoesNotLeakToHumans(t *testing.T) {
|
||||
human := &Principal{Org: "hanzo", User: "alice", Admin: true}
|
||||
if authorize(human, "GET", "applications", "admin", "hanzo-cloud") {
|
||||
t.Errorf("an org admin read a platform-owned application row")
|
||||
}
|
||||
}
|
||||
|
||||
// ONE ENVELOPE PER SURFACE. The compat verbs are verb-shaped and their clients
|
||||
// branch on a STRING status; the native surface is noun-shaped and keeps zip's
|
||||
// numeric-status error.
|
||||
func TestLegacyVerb_SelectsTheCompatSurfaceOnly(t *testing.T) {
|
||||
for path, want := range map[string]bool{
|
||||
"/v1/iam/get-account": true,
|
||||
"/v1/iam/get-application": true,
|
||||
"/v1/iam/add-organization": true,
|
||||
"/v1/iam/update-user": true,
|
||||
"/v1/iam/delete-membership": true,
|
||||
"/v1/iam/users": false, // native REST noun
|
||||
"/v1/iam/organizations": false,
|
||||
"/v1/iam/oauth/token": false, // RFC 6749 shape
|
||||
"/v1/iam/scim/v2/Users/x": false, // RFC 7644 shape
|
||||
"/healthz": false,
|
||||
"/v1/iam/": false,
|
||||
"/v1/other/get-thing": false, // not the IAM surface
|
||||
} {
|
||||
if got := legacyVerb(path); got != want {
|
||||
t.Errorf("legacyVerb(%q) = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package authz_test
|
||||
|
||||
// Tenant isolation on the LIST routes, driven through the real registered router.
|
||||
//
|
||||
// The bug this pins was a confused deputy, and it was invisible from either half
|
||||
// alone. The Guard authorizes on the query string — asking for a foreign org is
|
||||
// correctly refused — and then the handler filtered on `in.Owner` instead of on
|
||||
// the principal. A zip typed GET binds NOTHING from the request (a body is read
|
||||
// only for non-GET), so `in.Owner` arrived EMPTY on every REST call, took the
|
||||
// "empty owner lists everything" branch, and returned every tenant's rows.
|
||||
//
|
||||
// So the shape was: name someone else's org and get 403; name YOUR OWN org and
|
||||
// get the whole table. A status-code assertion passes throughout — only the body
|
||||
// shows it, which is why every case here reads the response.
|
||||
//
|
||||
// certs was the one lister that already resolved the owner via authz.Scope, and
|
||||
// it is included as the control: if the others ever regress, certs still passes
|
||||
// and the diff points straight at the cause.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/orm"
|
||||
)
|
||||
|
||||
func seedRole(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
r := orm.New[schema.Role](db)
|
||||
r.Owner, r.Name = owner, name
|
||||
r.SetId(owner + "/" + name)
|
||||
if err := r.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed role %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedInvitation(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
i := orm.New[schema.Invitation](db)
|
||||
i.Owner, i.Name = owner, name
|
||||
i.SetId(owner + "/" + name)
|
||||
if err := i.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed invitation %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedToken(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
tk := orm.New[schema.Token](db)
|
||||
tk.Owner, tk.Name = owner, name
|
||||
tk.SetId(owner + "/" + name)
|
||||
if err := tk.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed token %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedWebauthn(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
w := orm.New[schema.WebauthnCredential](db)
|
||||
w.Owner, w.Name = owner, name
|
||||
w.SetId(owner + "/" + name)
|
||||
if err := w.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed webauthn %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAuditLog(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.AuditLog](db)
|
||||
a.Owner, a.Name = owner, name
|
||||
a.Organization = owner
|
||||
a.SetId(owner + "/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed auditlog %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRoutesNeverLeakAnotherTenant is the regression. Each lister gets one
|
||||
// row in the caller's org and one in a foreign org; an org admin listing its own
|
||||
// org must see its own row and MUST NOT see the foreign one.
|
||||
//
|
||||
// The marker names are deliberately distinctive so a match cannot be incidental.
|
||||
func TestListRoutesNeverLeakAnotherTenant(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
seedRole(t, h.db, "hanzo", "role-mine-hanzo")
|
||||
seedRole(t, h.db, "orgb", "role-secret-orgb")
|
||||
seedInvitation(t, h.db, "hanzo", "invite-mine-hanzo")
|
||||
seedInvitation(t, h.db, "orgb", "invite-secret-orgb")
|
||||
seedAuditLog(t, h.db, "hanzo", "audit-mine-hanzo")
|
||||
seedAuditLog(t, h.db, "orgb", "audit-secret-orgb")
|
||||
seedCert(t, h.db, "orgb", "cert-secret-orgb", "")
|
||||
seedToken(t, h.db, "hanzo", "token-mine-hanzo")
|
||||
seedToken(t, h.db, "orgb", "token-secret-orgb")
|
||||
seedWebauthn(t, h.db, "hanzo", "wa-mine-hanzo")
|
||||
seedWebauthn(t, h.db, "orgb", "wa-secret-orgb")
|
||||
|
||||
boss := h.token(t, "hanzo/boss") // org admin of hanzo, and of nothing else
|
||||
|
||||
for _, c := range []struct {
|
||||
route string
|
||||
mine string
|
||||
foreign string
|
||||
}{
|
||||
{"/v1/iam/roles?owner=hanzo", "role-mine-hanzo", "role-secret-orgb"},
|
||||
{"/v1/iam/invitations?owner=hanzo", "invite-mine-hanzo", "invite-secret-orgb"},
|
||||
{"/v1/iam/audit-logs?owner=hanzo", "audit-mine-hanzo", "audit-secret-orgb"},
|
||||
{"/v1/iam/tokens?owner=hanzo", "token-mine-hanzo", "token-secret-orgb"},
|
||||
{"/v1/iam/webauthn-credentials?owner=hanzo", "wa-mine-hanzo", "wa-secret-orgb"},
|
||||
// organizations is the tenant registry — authz treats it as the ONE
|
||||
// exception to the reserved-owner gate, and the route is SuperAdmin-only,
|
||||
// so this case should refuse rather than list. Included so a future change
|
||||
// that opens it to tenants shows up here rather than silently.
|
||||
{"/v1/iam/organizations?owner=hanzo", "", "orgb"},
|
||||
{"/v1/iam/certs?owner=hanzo", "", "cert-secret-orgb"}, // control: already scoped
|
||||
} {
|
||||
t.Run(c.route, func(t *testing.T) {
|
||||
status, body := h.doBody(t, "GET", c.route, boss, nil)
|
||||
if status != 200 {
|
||||
t.Skipf("route answered %d, not a listing to check here", status)
|
||||
}
|
||||
if strings.Contains(body, c.foreign) {
|
||||
t.Errorf("LEAK: hanzo/boss listing its OWN org received orgb's %q.\n"+
|
||||
"The guard refuses ?owner=orgb, so the only way this row crosses the wire is a "+
|
||||
"handler that ignored the principal and listed every tenant.\nbody: %s",
|
||||
c.foreign, body)
|
||||
}
|
||||
if c.mine != "" && !strings.Contains(body, c.mine) {
|
||||
t.Errorf("scoping is too tight: hanzo/boss cannot see its OWN row %q.\nbody: %s", c.mine, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A foreign org must still be refused outright — the fix must not have moved the
|
||||
// refusal from the guard into a silently-empty listing.
|
||||
func TestListRoutesStillRefuseAForeignOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
|
||||
for _, route := range []string{
|
||||
"/v1/iam/roles?owner=orgb",
|
||||
"/v1/iam/invitations?owner=orgb",
|
||||
"/v1/iam/audit-logs?owner=orgb",
|
||||
"/v1/iam/certs?owner=orgb",
|
||||
} {
|
||||
status, body := h.doBody(t, "GET", route, boss, nil)
|
||||
if status == 200 {
|
||||
t.Errorf("%s returned 200 for a foreign org; want a refusal.\nbody: %s", route, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// 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
|
||||
// operator (operator-core) reconciles an IAM CR's spec.applications[]/users[] here,
|
||||
// binding the service-account OAuth apps that KMS/signers authenticate with, with NO
|
||||
// human admin in the loop. It is idempotent (create OR update by the natural key)
|
||||
// so a ~30s reconcile is a no-op once converged.
|
||||
//
|
||||
// Auth is a UNIFIED SERVICE TOKEN presented as `Authorization: Bearer <token>`,
|
||||
// validated constant-time against the first non-empty of HANZO_API_KEY /
|
||||
// KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — the same pipeline the old iam used. The
|
||||
// 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"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"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).
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
GrantTypes []string `json:"grantTypes"`
|
||||
RedirectUris []string `json:"redirectUris"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Cert string `json:"cert"`
|
||||
// Public declares a client that CANNOT hold a credential — a browser SPA,
|
||||
// a CLI, a desktop app. It proves itself with PKCE instead, and the token
|
||||
// endpoint treats "no stored secret" as exactly that (token.go: a secret is
|
||||
// verified only when one is stored). Without this flag every upsert minted
|
||||
// a secret, so a public client could never be registered at all and its
|
||||
// browser code->token exchange 401'd `invalid_client` forever.
|
||||
Public bool `json:"public"`
|
||||
// IsShared declares that this application serves EVERY organization, not only
|
||||
// the one named in Organization. It is the honest description of a brand app —
|
||||
// hanzo-id, hanzo-chat, a brand console — whose customers each live in their own
|
||||
// tenant: self-service onboarding moves a founder OUT of the brand org, so
|
||||
// `user.Owner != app.Organization` is the steady state and the app really does
|
||||
// serve every org. Application.ServesOrg reads it as one of the three ways to
|
||||
// say yes.
|
||||
//
|
||||
// A POINTER because omission must PRESERVE. This upsert is the operator's
|
||||
// steady-state reconcile and most callers say nothing about sharing; a plain
|
||||
// bool would read as false on every one of them and silently un-share an app —
|
||||
// the same shape of accident that de-secreted apps through update-application.
|
||||
// Nil means "not stated, leave it"; only an explicit true or false moves it.
|
||||
IsShared *bool `json:"isShared"`
|
||||
// ExpireInHours and RefreshExpireInHours are the application's token
|
||||
// lifetimes. They are the ONLY declarative way to say that a refresh token
|
||||
// must OUTLIVE its access token: with neither stated, oidc.refreshTTL clamps
|
||||
// the refresh lifetime to the access lifetime, so the refresh_token grant the
|
||||
// registration advertises expires at the same instant as the token it was
|
||||
// meant to renew and can never be exercised. `hanzo-cli` sat in exactly that
|
||||
// state — a browser re-login every hour, and a live refresh returning 401.
|
||||
//
|
||||
// POINTERS, for the same reason as IsShared: a plain float would read as 0 on
|
||||
// every reconcile that says nothing and reset a deliberate lifetime back to
|
||||
// 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
|
||||
// deployment can declare the applications it needs and run the same declaration
|
||||
// on every environment and on every redeploy.
|
||||
//
|
||||
// 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.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
|
||||
}
|
||||
if bad := in.check(); bad != nil {
|
||||
return bad, nil
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
return refuse(400, "name is required"), nil
|
||||
}
|
||||
|
||||
existing, err := store.GetApplicationByName(ctx, db, "admin", in.Name)
|
||||
if err != nil {
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
var existingSecret string
|
||||
if existing != nil {
|
||||
existingSecret = existing.ClientSecret
|
||||
}
|
||||
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 = in.ClientId
|
||||
existing.ClientSecret = in.ClientSecret
|
||||
existing.Organization = pick(in.Organization, existing.Organization)
|
||||
if in.DisplayName != "" {
|
||||
existing.DisplayName = in.DisplayName
|
||||
}
|
||||
if len(in.GrantTypes) > 0 {
|
||||
existing.GrantTypes = in.GrantTypes
|
||||
}
|
||||
if len(in.RedirectUris) > 0 {
|
||||
existing.RedirectUris = in.RedirectUris
|
||||
}
|
||||
if in.Cert != "" {
|
||||
existing.Cert = in.Cert
|
||||
}
|
||||
if in.IsShared != nil {
|
||||
existing.IsShared = *in.IsShared
|
||||
}
|
||||
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 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", 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(in.ExpireInHours, schema.DefaultExpireInHours)
|
||||
a.RefreshExpireInHours = ttl(in.RefreshExpireInHours, 0)
|
||||
// A new app is single-tenant unless it says otherwise — fail closed.
|
||||
a.IsShared = in.IsShared != nil && *in.IsShared
|
||||
a.Model = model
|
||||
a.SetId("admin/" + in.Name)
|
||||
if err := a.CreateCtx(ctx); err != nil {
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
}
|
||||
return done(action, &credential{
|
||||
ClientId: in.ClientId, ClientSecret: in.ClientSecret,
|
||||
Name: in.Name, Organization: in.Organization,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
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
|
||||
// declare the accounts it needs and re-run that declaration safely.
|
||||
//
|
||||
// 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.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
|
||||
}
|
||||
if bad := in.check(); bad != nil {
|
||||
return bad, nil
|
||||
}
|
||||
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 in.Password != "" {
|
||||
h, err := cred.Hash(in.Password)
|
||||
if err != nil {
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
|
||||
existing, err := store.GetUserByName(ctx, db, in.Owner, in.Name)
|
||||
if err != nil {
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
action := "created"
|
||||
if existing != nil {
|
||||
action = "updated"
|
||||
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 refuse(500, "server_error"), nil
|
||||
}
|
||||
} else {
|
||||
// A new row obeys THE username rule; an existing one is found above and
|
||||
// merely updated, so a legacy name is never rewritten by an upsert that
|
||||
// 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(in.Name)
|
||||
if err != nil {
|
||||
return refuse(400, err.Error()), nil
|
||||
}
|
||||
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 = 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(in.Owner + "/" + in.Name)
|
||||
if err := u.CreateCtx(ctx); err != nil {
|
||||
return refuse(500, "server_error"), nil
|
||||
}
|
||||
}
|
||||
return done(action, &account{Name: in.Name, Owner: in.Owner}), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// resolveSecret decides the credential an upsert stores. It is the ONE place
|
||||
// public-vs-confidential is settled, split out so the rule is testable without
|
||||
// a store:
|
||||
//
|
||||
// - public -> NO secret. That absence is exactly what the token endpoint
|
||||
// reads as "PKCE, do not demand client auth", so it must also
|
||||
// CLEAR one left by an earlier confidential registration.
|
||||
// - explicit -> honour it; rotation is deliberate.
|
||||
// - existing app -> preserve what it has, INCLUDING an empty secret. Minting
|
||||
// one because "the stored secret is empty" would silently
|
||||
// turn a public client confidential on the next reconcile.
|
||||
// - brand new -> mint one.
|
||||
func resolveSecret(public bool, requested string, hasExisting bool, existing string) string {
|
||||
switch {
|
||||
case public:
|
||||
return ""
|
||||
case requested != "":
|
||||
return requested
|
||||
case hasExisting:
|
||||
return existing
|
||||
default:
|
||||
return randomSecret()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if org = strings.TrimSpace(org); org != "" {
|
||||
return "cert-" + org
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
// "back to the default".
|
||||
func ttl(declared *float64, cur float64) float64 {
|
||||
if declared == nil {
|
||||
return cur
|
||||
}
|
||||
return *declared
|
||||
}
|
||||
|
||||
// pick returns a if non-empty (trimmed), else b.
|
||||
func pick(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// randomSecret returns a 32-byte URL-safe random client secret.
|
||||
func randomSecret() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"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/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const svcToken = "svc-token-secret-value"
|
||||
|
||||
func boot(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "boot.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-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return app, db
|
||||
}
|
||||
|
||||
func post(t *testing.T, app *zip.App, path, token, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := testhttp.Do(app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
func TestUpsertApplication_createThenIdempotentUpdate(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
body := `{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","grantTypes":["client_credentials"]}`
|
||||
|
||||
// Create — a secret is generated, action=created.
|
||||
st, m := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
|
||||
if st != 200 || m["status"] != "ok" || m["action"] != "created" {
|
||||
t.Fatalf("create: status=%d body=%v", st, m)
|
||||
}
|
||||
data, _ := m["data"].(map[string]any)
|
||||
secret, _ := data["clientSecret"].(string)
|
||||
if secret == "" {
|
||||
t.Fatalf("no clientSecret generated: %v", data)
|
||||
}
|
||||
if a, _ := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-kms"); a == nil {
|
||||
t.Fatalf("app not persisted")
|
||||
}
|
||||
|
||||
// Re-upsert with NO secret — idempotent: action=updated, the SAME secret is
|
||||
// preserved (no rotation storm on a steady-state reconcile).
|
||||
st2, m2 := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
|
||||
if st2 != 200 || m2["action"] != "updated" {
|
||||
t.Fatalf("re-upsert: status=%d body=%v", st2, m2)
|
||||
}
|
||||
data2, _ := m2["data"].(map[string]any)
|
||||
if data2["clientSecret"] != secret {
|
||||
t.Fatalf("clientSecret rotated on idempotent re-upsert: %v → %v", secret, data2["clientSecret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_createHashesPassword(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
body := `{"owner":"hanzo","name":"svc-signer","password":"s3cret","isAdmin":false}`
|
||||
st, m := post(t, app, "/v1/iam/admin/users/upsert", svcToken, body)
|
||||
if st != 200 || m["action"] != "created" {
|
||||
t.Fatalf("create user: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "svc-signer")
|
||||
if u == nil || u.PasswordHash == "" || u.PasswordHash == "s3cret" {
|
||||
t.Fatalf("password not hashed: %+v", u)
|
||||
}
|
||||
if u.PasswordType != "argon2id" {
|
||||
t.Fatalf("passwordType = %q, want argon2id", u.PasswordType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_requiresServiceToken(t *testing.T) {
|
||||
app, _ := boot(t)
|
||||
body := `{"name":"x"}`
|
||||
// No token → 401.
|
||||
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "", body); st != 401 {
|
||||
t.Fatalf("no-token status = %d, want 401", st)
|
||||
}
|
||||
// Wrong token → 401.
|
||||
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "wrong-token", body); st != 401 {
|
||||
t.Fatalf("wrong-token status = %d, want 401", st)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// isShared is the honest declaration that an application serves EVERY organization,
|
||||
// and the upsert is the ONE door that can set it without collateral damage:
|
||||
// update-application is a full REPLACE over a read that MASKS the client secret, so
|
||||
// the natural read-modify-write de-secrets the app. This endpoint merges field by
|
||||
// field and preserves the credential, which is why the brand-app flags are set here.
|
||||
//
|
||||
// The semantics that make it safe to call on a live fleet: an omitted isShared
|
||||
// PRESERVES whatever is stored. Most operator reconciles say nothing about sharing,
|
||||
// and a plain bool would read as false on every one of them and silently un-share
|
||||
// the apps — turning the steady-state reconcile into a recurring outage for every
|
||||
// self-service customer.
|
||||
func TestUpsertApplication_isSharedOmittedPreserves(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
ctx := context.Background()
|
||||
const path = "/v1/iam/admin/applications/upsert"
|
||||
base := `{"organization":"hanzo","name":"hanzo-id","clientId":"hanzo-id"`
|
||||
|
||||
// Created without the field: single-tenant, fail closed.
|
||||
if st, m := post(t, app, path, svcToken, base+`}`); st != 200 || m["action"] != "created" {
|
||||
t.Fatalf("create: status=%d body=%v", st, m)
|
||||
}
|
||||
a, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
|
||||
if a == nil || a.IsShared {
|
||||
t.Fatalf("a new app must default to single-tenant, got isShared=%v", a.IsShared)
|
||||
}
|
||||
|
||||
// Declared shared.
|
||||
if st, _ := post(t, app, path, svcToken, base+`,"isShared":true}`); st != 200 {
|
||||
t.Fatalf("set isShared: status=%d", st)
|
||||
}
|
||||
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
|
||||
if !a.IsShared {
|
||||
t.Fatalf("isShared:true did not persist")
|
||||
}
|
||||
|
||||
// The steady-state reconcile: the field is omitted, and must NOT un-share.
|
||||
if st, _ := post(t, app, path, svcToken, base+`}`); st != 200 {
|
||||
t.Fatalf("reconcile: status=%d", st)
|
||||
}
|
||||
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
|
||||
if !a.IsShared {
|
||||
t.Fatalf("an omitted isShared UN-SHARED the app; every operator reconcile would " +
|
||||
"lock out every self-service customer of this brand")
|
||||
}
|
||||
|
||||
// Un-sharing stays possible, it just has to be DELIBERATE.
|
||||
if st, _ := post(t, app, path, svcToken, base+`,"isShared":false}`); st != 200 {
|
||||
t.Fatalf("clear isShared: status=%d", st)
|
||||
}
|
||||
a, _ = store.GetApplicationByName(ctx, db, "admin", "hanzo-id")
|
||||
if a.IsShared {
|
||||
t.Fatalf("an explicit isShared:false must un-share")
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this door was chosen over update-application: it does not touch the
|
||||
// credential. Pinned together with the flag so a future refactor cannot reintroduce
|
||||
// the de-secret trap on the one path the fleet is configured through.
|
||||
func TestUpsertApplication_isSharedDoesNotDisturbTheSecret(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
ctx := context.Background()
|
||||
const path = "/v1/iam/admin/applications/upsert"
|
||||
base := `{"organization":"hanzo","name":"hanzo-chat","clientId":"hanzo-chat"`
|
||||
|
||||
if st, _ := post(t, app, path, svcToken, base+`}`); st != 200 {
|
||||
t.Fatalf("create failed")
|
||||
}
|
||||
before, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-chat")
|
||||
if before.ClientSecret == "" {
|
||||
t.Fatalf("expected a generated secret to protect")
|
||||
}
|
||||
|
||||
if st, _ := post(t, app, path, svcToken, base+`,"isShared":true}`); st != 200 {
|
||||
t.Fatalf("set isShared failed")
|
||||
}
|
||||
after, _ := store.GetApplicationByName(ctx, db, "admin", "hanzo-chat")
|
||||
if after.ClientSecret != before.ClientSecret {
|
||||
t.Fatalf("flipping isShared changed the client secret %q → %q; a confidential "+
|
||||
"client would have been silently turned public", before.ClientSecret, after.ClientSecret)
|
||||
}
|
||||
if !after.IsShared {
|
||||
t.Fatalf("isShared did not persist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Token lifetimes are DECLARABLE through the upsert, and an omitted lifetime
|
||||
// PRESERVES what the app has. Without the first half there is no declarative way
|
||||
// to say a refresh token must outlive its access token, and oidc.refreshTTL
|
||||
// clamps the refresh lifetime to the access lifetime — the registration
|
||||
// advertises a refresh_token grant that can never be exchanged, which is where
|
||||
// hanzo-cli's hourly browser re-login came from. Without the second half every
|
||||
// steady-state converge would reset the lifetime it just set.
|
||||
func TestUpsertApplication_tokenLifetimes(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
const path = "/v1/iam/admin/applications/upsert"
|
||||
get := func() *schema.Application {
|
||||
t.Helper()
|
||||
a, err := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-cli")
|
||||
if err != nil || a == nil {
|
||||
t.Fatalf("load hanzo-cli: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Create with a declared refresh lifetime; the access lifetime is unstated
|
||||
// and must fall back to the ONE default, not to zero.
|
||||
if st, m := post(t, app, path, svcToken,
|
||||
`{"organization":"hanzo","name":"hanzo-cli","refreshExpireInHours":720}`); st != 200 || m["action"] != "created" {
|
||||
t.Fatalf("create: status=%d body=%v", st, m)
|
||||
}
|
||||
a := get()
|
||||
if a.RefreshExpireInHours != 720 {
|
||||
t.Fatalf("refreshExpireInHours = %v, want 720", a.RefreshExpireInHours)
|
||||
}
|
||||
if a.ExpireInHours != schema.DefaultExpireInHours {
|
||||
t.Fatalf("expireInHours = %v, want the default %v", a.ExpireInHours, schema.DefaultExpireInHours)
|
||||
}
|
||||
if a.RefreshExpireInHours <= a.ExpireInHours {
|
||||
t.Fatal("the refresh lifetime must outlive the access lifetime")
|
||||
}
|
||||
|
||||
// A converge that says nothing about lifetimes preserves both.
|
||||
if st, _ := post(t, app, path, svcToken, `{"organization":"hanzo","name":"hanzo-cli"}`); st != 200 {
|
||||
t.Fatalf("re-upsert failed: %d", st)
|
||||
}
|
||||
if a := get(); a.RefreshExpireInHours != 720 || a.ExpireInHours != schema.DefaultExpireInHours {
|
||||
t.Fatalf("an omitted lifetime was not preserved: expire=%v refresh=%v", a.ExpireInHours, a.RefreshExpireInHours)
|
||||
}
|
||||
|
||||
// A stated lifetime moves it — including an explicit 0, the way a document
|
||||
// says "back to the default".
|
||||
if st, _ := post(t, app, path, svcToken,
|
||||
`{"organization":"hanzo","name":"hanzo-cli","expireInHours":8,"refreshExpireInHours":0}`); st != 200 {
|
||||
t.Fatalf("update failed: %d", st)
|
||||
}
|
||||
if a := get(); a.ExpireInHours != 8 || a.RefreshExpireInHours != 0 {
|
||||
t.Fatalf("stated lifetimes not applied: expire=%v refresh=%v", a.ExpireInHours, a.RefreshExpireInHours)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import "testing"
|
||||
|
||||
// A public client must STAY public across reconciles. An upsert that omits
|
||||
// `public` (an operator reconcile, a probe, any caller that only sets a name)
|
||||
// must not read "no stored secret" as "mint one" — that silently converts the
|
||||
// client back to confidential and every browser login starts failing
|
||||
// `invalid_client` with nothing in the provision document changed to explain it.
|
||||
func TestUpsertApplication_PublicStaysPublicAcrossReconciles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
public bool
|
||||
reqSecret string
|
||||
existingSecret string
|
||||
hasExisting bool
|
||||
wantSecretEmpty bool
|
||||
wantSecretEquals string
|
||||
}{
|
||||
{name: "public clears an inherited secret", public: true, existingSecret: "old", hasExisting: true, wantSecretEmpty: true},
|
||||
{name: "omitting public preserves empty", hasExisting: true, existingSecret: "", wantSecretEmpty: true},
|
||||
{name: "omitting public preserves a secret", hasExisting: true, existingSecret: "keepme", wantSecretEquals: "keepme"},
|
||||
{name: "explicit secret wins", reqSecret: "rotated", hasExisting: true, existingSecret: "old", wantSecretEquals: "rotated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolveSecret(tc.public, tc.reqSecret, tc.hasExisting, tc.existingSecret)
|
||||
switch {
|
||||
case tc.wantSecretEmpty && got != "":
|
||||
t.Errorf("secret = %q, want empty", got)
|
||||
case tc.wantSecretEquals != "" && got != tc.wantSecretEquals:
|
||||
t.Errorf("secret = %q, want %q", got, tc.wantSecretEquals)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A brand-new confidential client still gets one.
|
||||
if resolveSecret(false, "", false, "") == "" {
|
||||
t.Error("a new confidential client must be minted a secret")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
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.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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
|
||||
// typed zip handler over hanzoai/orm; the orm string key is "owner/name". Reads
|
||||
// scope to one owner (organization); writes address one cert by its (owner,
|
||||
// name) key.
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// Handler binds the certs operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the certs CRUD routes on app against db. Reads are zip.Get,
|
||||
// writes are zip.Post; the create/update body is the schema.Cert row itself, so
|
||||
// the HTTP contract and the stored entity never drift.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/certs", h.List, zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs", h.Create, zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/get", h.Get, zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/update", h.Update, zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/delete", h.Delete, zip.WithTags("certs"))
|
||||
}
|
||||
|
||||
// Ref addresses one cert by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of certs.
|
||||
type ListOutput struct {
|
||||
Certs []*schema.Cert `json:"certs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// List returns your organization's signing certificates, newest first — the keys
|
||||
// the tokens your applications verify are signed with. Private key material is
|
||||
// masked.
|
||||
//
|
||||
// You see your own organization's certificates and no one else's; which
|
||||
// organization that is comes from your credentials, not from the request, so a
|
||||
// query parameter can never widen the listing.
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
owner, err := authz.Scope(ctx, in.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.Cert](h.db)
|
||||
if owner != "" {
|
||||
q = q.Filter("owner", owner)
|
||||
}
|
||||
certs, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
out := make([]*schema.Cert, len(certs))
|
||||
for i, c := range certs {
|
||||
out[i] = c.Mask()
|
||||
}
|
||||
return &ListOutput{Certs: out, Total: len(out)}, nil
|
||||
}
|
||||
|
||||
// Get returns one signing certificate — its algorithm, its validity window and
|
||||
// its public half. The private key is masked.
|
||||
func (h *Handler) Get(_ context.Context, in *Ref) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return cert.Mask(), nil
|
||||
}
|
||||
|
||||
// Create adds a signing certificate your applications can verify tokens against
|
||||
// — the call you make to bring your own key, or to stage the next one before a
|
||||
// rotation. A name already used in your organization is refused.
|
||||
func (h *Handler) Create(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("cert already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// orm.New binds the store and applies defaults; overlay the decoded row,
|
||||
// then restore the bound Model so its db handle survives the assignment.
|
||||
cert := orm.New[schema.Cert](h.db)
|
||||
model := cert.Model
|
||||
*cert = *in
|
||||
cert.Model = model
|
||||
if cert.CreatedTime == "" {
|
||||
cert.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
cert.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := cert.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Update changes a signing certificate's settings. What it is called does not
|
||||
// change, and neither does when it was added.
|
||||
func (h *Handler) Update(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
// Keep the loaded Model (id, createdAt, key, snapshot) and the original
|
||||
// creation stamp; overlay the decoded domain fields onto them.
|
||||
model := cert.Model
|
||||
created := cert.CreatedTime
|
||||
*cert = *in
|
||||
cert.Model = model
|
||||
cert.Owner, cert.Name = in.Owner, in.Name
|
||||
if created != "" {
|
||||
cert.CreatedTime = created
|
||||
}
|
||||
if err := cert.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Delete removes a signing certificate. Tokens signed with it can no longer be
|
||||
// verified, so retire it only once nothing is still presenting them.
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := cert.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("cert not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package certs
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/certs", zip.Doc{
|
||||
Description: "Returns your organization's signing certificates, newest first — the keys\nthe tokens your applications verify are signed with. Private key material is\nmasked.\n\nYou see your own organization's certificates and no one else's; which\norganization that is comes from your credentials, not from the request, so a\nquery parameter can never widen the listing.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/certs", zip.Doc{
|
||||
Description: "Adds a signing certificate your applications can verify tokens against\n— the call you make to bring your own key, or to stage the next one before a\nrotation. A name already used in your organization is refused.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/certs/delete", zip.Doc{
|
||||
Description: "Removes a signing certificate. Tokens signed with it can no longer be\nverified, so retire it only once nothing is still presenting them.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/certs/get", zip.Doc{
|
||||
Description: "Returns one signing certificate — its algorithm, its validity window and\nits public half. The private key is masked.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/certs/update", zip.Doc{
|
||||
Description: "Changes a signing certificate's settings. What it is called does not\nchange, and neither does when it was added.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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 Casdoor database and the v2 orm store and prints the
|
||||
// entity in the v1 the legacy surface database and the v2 orm store and prints the
|
||||
// absolute drift. Cutover (MIGRATION.md §5) is blocked until drift is 0.
|
||||
//
|
||||
// Read-only by construction: the v1 side issues only SELECT COUNT(*); the v2
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
)
|
||||
|
||||
// pair maps a v1 Casdoor table to the v2 orm kind that mirrors it.
|
||||
// pair maps a v1 the legacy surface table to the v2 orm kind that mirrors it.
|
||||
type pair struct {
|
||||
v1Table string
|
||||
v2Kind string
|
||||
@@ -44,6 +45,8 @@ var mapping = []pair{
|
||||
{"token", "tokens"},
|
||||
{"record", "audit_logs"},
|
||||
{"invitation", "invitations"},
|
||||
{"web3_nonce", "challenges"},
|
||||
{"wallet_link", "wallets"},
|
||||
}
|
||||
|
||||
// Run writes a tab-aligned per-entity drift report to w. ctx bounds every
|
||||
@@ -56,7 +59,7 @@ func Run(ctx context.Context, v2 orm.DB, legacyDSN string, w io.Writer) error {
|
||||
if scheme == "" {
|
||||
return errors.New("compare: --legacy DSN must start with postgres:// or mysql://")
|
||||
}
|
||||
return fmt.Errorf("compare: no %q driver in this build — rebuild with `-tags migration` to read the v1 Casdoor database", scheme)
|
||||
return fmt.Errorf("compare: no %q driver in this build — rebuild with `-tags migration` to read the v1 the legacy surface database", scheme)
|
||||
}
|
||||
|
||||
legacy, err := sql.Open(driver, legacyArg(scheme, legacyDSN))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
//go:build migration
|
||||
|
||||
// This file is linked only in `go build -tags migration`. It registers the v1
|
||||
// Casdoor database drivers (Postgres via pgx, MySQL) so `iam2 compare` can
|
||||
// the legacy surface database drivers (Postgres via pgx, MySQL) so `iam compare` can
|
||||
// read the legacy store. The default build omits it, keeping the serving
|
||||
// binary free of any non-SQLite driver.
|
||||
package compare
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
//go:build !migration
|
||||
|
||||
package compare
|
||||
|
||||
// legacyDriver reports no driver in the default build. `iam2 compare` needs a
|
||||
// legacyDriver reports no driver in the default build. `iam compare` needs a
|
||||
// `-tags migration` build to link the v1 Postgres/MySQL driver — see
|
||||
// legacy_migration.go. This keeps the serving binary free of non-SQLite drivers.
|
||||
func legacyDriver(string) (string, bool) { return "", false }
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// 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
|
||||
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
|
||||
// portal — hard-codes the legacy verb spellings and the `{status,data,data2}`
|
||||
// envelope, while iam's native surface is REST (`/v1/iam/users`,
|
||||
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
|
||||
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
|
||||
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
|
||||
// no redaction is reimplemented here.
|
||||
//
|
||||
// Authorization is NOT reimplemented either. These paths are not in authz's
|
||||
// public allowlist, so the Guard (app.Use, registered first) authenticates every
|
||||
// request AND authorizes the read against the exact (owner, name) it addresses —
|
||||
// resolved by the same authz.ReadTarget the handlers use, so a handler can never
|
||||
// reach a row the Guard did not authorize. Each handler then re-scopes the query
|
||||
// owner through authz.Scope: a SuperAdmin may list any owner (empty = all
|
||||
// tenants), everyone else is pinned to their own org, so a request parameter can
|
||||
// never widen a read past the caller's authority.
|
||||
package compat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// unauthorized is v1's refusal message, verbatim — the envelope a denied caller
|
||||
// receives from a handler-authorized read (the Guard's own refusals are raw 403s).
|
||||
const unauthorized = "auth:Unauthorized operation"
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the the legacy surface read-verb aliases. The mask argument is the
|
||||
// entity's schema.Mask method (the ONE redaction contract) for entities that
|
||||
// carry secrets, or nil for those that do not — nil means "no field to strip",
|
||||
// not "skip a needed redaction". Writes ride a companion file.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
// List reads — `?owner=&p=&pageSize=` (legacy shape). Owner-scoped by authz.
|
||||
app.Get("/v1/iam/get-organizations", listHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-global-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-applications", listHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-providers", listHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-certs", listHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-roles", listHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permissions", listHandler[schema.Permission](db, nil))
|
||||
app.Get("/v1/iam/get-invitations", listHandler[schema.Invitation](db, nil))
|
||||
app.Get("/v1/iam/get-records", listHandler[schema.AuditLog](db, nil))
|
||||
|
||||
// Single reads — `?id=<owner>/<name>` (or `?owner=&name=`).
|
||||
app.Get("/v1/iam/get-organization", getHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-user", userGetHandler(db))
|
||||
app.Get("/v1/iam/get-application", getHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-provider", getHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-cert", getHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-role", getHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permission", getHandler[schema.Permission](db, nil))
|
||||
|
||||
// resolve-key — the WRITE-ONLY ingest door and the dual of get-user?accessKey. It
|
||||
// turns a publishable pk- into just the ORG that holds it (never a principal), for
|
||||
// cloud's ingest boundary. Its target rides in ?accessKey= (no owner/name for the
|
||||
// Guard to authorize), so it is handler-authorized (authz.handlerAuthorizedExact)
|
||||
// and the handler authorizes itself behind CapPublishableResolve.
|
||||
app.Get("/v1/iam/resolve-key", resolveKeyHandler(db))
|
||||
|
||||
// get-organization-projects — the console ScopeSwitcher's project list, keyed by
|
||||
// ?organization= (not ?owner=). Its target rides in ?organization, which the Guard
|
||||
// does not inspect generically, so this path is handler-authorized (authz's
|
||||
// handlerAuthorizedPrefixes): the Guard authenticates, and this handler scopes the
|
||||
// requested org through authz.Scope — a non-super is pinned to its own org, so any
|
||||
// authenticated member lists exactly its own org's projects (the ScopeSwitcher is
|
||||
// shown to every user, not only admins, so this read is intentionally not
|
||||
// admin-gated the way the generic listers are).
|
||||
app.Get("/v1/iam/get-organization-projects", orgProjectsHandler(db))
|
||||
|
||||
// get-organization-workspaces — the console ScopeSwitcher's workspace list, the
|
||||
// tier above projects in the Organization → Workspace → Project hierarchy. Keyed
|
||||
// by ?organization= exactly like get-organization-projects, so it is
|
||||
// handler-authorized (authz's handlerAuthorizedPrefixes) the same way: the Guard
|
||||
// authenticates, and this handler scopes the requested org through authz.Scope so
|
||||
// a request parameter can never widen the read past the caller's tenant.
|
||||
app.Get("/v1/iam/get-organization-workspaces", orgWorkspacesHandler(db))
|
||||
|
||||
// The the legacy surface WRITE verbs (companion file), over the same store + authz seam.
|
||||
routeWrites(app, db)
|
||||
}
|
||||
|
||||
// orgProjectsHandler returns one organization's projects — what a scope switcher
|
||||
// lists so somebody can move between them.
|
||||
//
|
||||
// You see your own organization and no other, whatever the request asks for.
|
||||
func orgProjectsHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
requested := c.Query("organization")
|
||||
if requested == "" {
|
||||
requested = c.Query("owner")
|
||||
}
|
||||
owner, err := authz.Scope(ctx, requested)
|
||||
if err != nil {
|
||||
return authz.Deny(c, err)
|
||||
}
|
||||
q := orm.TypedQuery[schema.Project](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
rows, err := q.Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// orgWorkspacesHandler returns one organization's workspaces — what a scope
|
||||
// switcher lists so somebody can move between them.
|
||||
//
|
||||
// You see your own organization and no other, whatever the request asks for.
|
||||
func orgWorkspacesHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
requested := c.Query("organization")
|
||||
if requested == "" {
|
||||
requested = c.Query("owner")
|
||||
}
|
||||
owner, err := authz.Scope(ctx, requested)
|
||||
if err != nil {
|
||||
return authz.Deny(c, err)
|
||||
}
|
||||
q := orm.TypedQuery[schema.Workspace](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
rows, err := q.Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// listHandler lists one kind of record in your organization — the older spelling
|
||||
// of the collection reads on the REST surface, over the same data and the same
|
||||
// permissions.
|
||||
//
|
||||
// Secrets are stripped from every row. Send both a page number and a page size to
|
||||
// page, and the total comes back alongside; send neither and you get the whole
|
||||
// set. You see your own organization and no other, whatever the request asks for.
|
||||
//
|
||||
// Scoping note (intentional, fail-closed): iam's ownership model is mixed —
|
||||
// users/roles/permissions are owned by their tenant org, while organizations/
|
||||
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
|
||||
// (Scope → the requested owner, empty = all) therefore lists every entity, which
|
||||
// is the console-admin path. A non-super is pinned by Scope to its own org, so it
|
||||
// lists its tenant-owned entities correctly and is refused the platform-owned
|
||||
// lists at the Guard (owner "" or "admin" both deny) — a safe 403, never another
|
||||
// tenant's rows. Non-super, membership-scoped views of the platform-owned
|
||||
// entities (e.g. an org console's own app list keyed on Application.Organization)
|
||||
// are a separate, additive surface, not a silent behavior of this generic lister.
|
||||
func listHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, err := authz.Scope(ctx, c.Query("owner"))
|
||||
if err != nil {
|
||||
return authz.Deny(c, err)
|
||||
}
|
||||
|
||||
base := func() *orm.ModelQuery[T] {
|
||||
q := orm.TypedQuery[T](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
page, size, paginated := pageParams(c)
|
||||
if !paginated {
|
||||
rows, err := base().Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, maskAll(rows, mask))
|
||||
}
|
||||
|
||||
total, err := base().Count(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
rows, err := base().Order("Name").Limit(size).Offset((page - 1) * size).GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return c.JSON(200, httpx.Response{Status: "ok", Data: maskAll(rows, mask), Data2: total})
|
||||
}
|
||||
}
|
||||
|
||||
// getHandler reads one record — the older spelling of the single reads on the
|
||||
// REST surface, over the same data and the same permissions.
|
||||
//
|
||||
// Secrets are stripped. Naming a record in another organization does not reach
|
||||
// it, however the request spells it.
|
||||
func getHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name := authz.ReadTarget(c)
|
||||
if name == "" {
|
||||
return httpx.Err(c, "id (owner/name) or name is required")
|
||||
}
|
||||
scoped, err := authz.ScopeFor(ctx, c.Path(), owner, name)
|
||||
if err != nil {
|
||||
return authz.Deny(c, err)
|
||||
}
|
||||
row, err := orm.TypedQuery[T](db).Filter("Owner=", scoped).Filter("Name=", name).First()
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return httpx.Err(c, "the entity does not exist")
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if mask != nil {
|
||||
row = mask(row)
|
||||
}
|
||||
return httpx.Ok(c, row)
|
||||
}
|
||||
}
|
||||
|
||||
// userGetHandler reads one person, two ways.
|
||||
//
|
||||
// Name them and it is an ordinary read, with secrets stripped. Or hand it a
|
||||
// SECRET API key and it answers with the person that key belongs to — how a
|
||||
// service of yours turns a credential on an incoming request into an identity.
|
||||
//
|
||||
// A publishable key resolves to nobody here, deliberately: it is safe to ship in
|
||||
// a browser precisely because it names an organization and never a person.
|
||||
//
|
||||
// get-user is handler-authorized (authz.handlerAuthorizedExact) because the key
|
||||
// variant carries no owner/name for the Guard to authorize; so the owner/name
|
||||
// variant reinstates the SAME read authorization the Guard applies, through the ONE
|
||||
// policy function (authz.Can) — identical behavior, a cross-tenant or non-self read
|
||||
// still refused 403 — then reuses the generic getHandler verbatim for resolution and
|
||||
// redaction. No authz and no CRUD is reimplemented.
|
||||
func userGetHandler(db orm.DB) zip.Handler {
|
||||
byOwnerName := getHandler(db, (*schema.User).Mask)
|
||||
return func(c *zip.Ctx) error {
|
||||
if key := strings.TrimSpace(c.Query("accessKey")); key != "" {
|
||||
return resolveUserByAccessKey(c, db, key)
|
||||
}
|
||||
owner, name := authz.ReadTarget(c)
|
||||
if !authz.Can(c.Context(), "GET", "users", owner, name) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return byOwnerName(c)
|
||||
}
|
||||
}
|
||||
|
||||
// keyUser is the minimal principal projection get-user?accessKey returns — EXACTLY
|
||||
// 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 (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 {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// resolveUserByAccessKey authenticates the SERVICE caller and resolves an API key to
|
||||
// its owning principal. The gate is service-only and fail-secure: the caller must be
|
||||
// a confidential app (p.App != "") holding CapKeyResolve — a human, even a
|
||||
// SuperAdmin, is refused, because a capability is held vacuously by non-apps and key
|
||||
// resolution is a machine-identity boundary, never an interactive admin action.
|
||||
//
|
||||
// THE `msg` STAYS UNIFORM AND THE `code` SAYS WHY. Every unresolvable key answers the
|
||||
// same not-exist sentence every other get- verb uses, so nothing that reads the prose
|
||||
// can tell a missing key from a denied one. The machine-readable reason rides beside
|
||||
// it because THE GATE ABOVE IS THE BOUNDARY, not the vagueness of this sentence: a
|
||||
// caller that reaches this line has already proven it is a confidential app holding
|
||||
// CapKeyResolve, and such a caller can resolve any key it likes to a full principal.
|
||||
// Telling it which refusal occurred discloses nothing it could not already obtain,
|
||||
// and withholding it is what made a revoked key indistinguishable from a deleted org
|
||||
// for every human downstream. There is no anonymous reader of this envelope to
|
||||
// oracle.
|
||||
func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
|
||||
ctx := c.Context()
|
||||
p, ok := authz.From(ctx)
|
||||
if !ok || p.App == "" || !authz.Allowed(p, authz.CapKeyResolve) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
u, err := store.UserByAccessKey(ctx, db, key)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, keyUser{Owner: u.Owner, Name: u.Name, Email: u.Email, IsAdmin: u.IsAdmin})
|
||||
}
|
||||
|
||||
// maskAll redacts every row through the entity's Mask (a no-op when the entity
|
||||
// has no secrets, i.e. mask is nil). Mask returns a copy, so the slice is
|
||||
// rewritten in place with the masked copies.
|
||||
func maskAll[T any](rows []*T, mask func(*T) *T) []*T {
|
||||
if mask == nil {
|
||||
return rows
|
||||
}
|
||||
for i, r := range rows {
|
||||
rows[i] = mask(r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// pageParams returns (page, size, paginated). A list paginates ONLY when BOTH
|
||||
// `p` and `pageSize` are present and positive; otherwise the caller returns the
|
||||
// full set (v1 semantics).
|
||||
func pageParams(c *zip.Ctx) (page, size int, paginated bool) {
|
||||
pp, ps := c.Query("p"), c.Query("pageSize")
|
||||
if pp == "" || ps == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
page, _ = strconv.Atoi(pp)
|
||||
size, _ = strconv.Atoi(ps)
|
||||
if page <= 0 || size <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return page, size, true
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
// End-to-end tests for the legacy verb aliases, driven through the REAL registered
|
||||
// router (routes.Route installs the authz Guard between the public group and the
|
||||
// authed routes; compat is registered after it, so gated). Every
|
||||
// case is a HTTP request a live console/gateway client sends. The assertions are
|
||||
// the three contracts a backend swap depends on: the v1 {status,data,data2}
|
||||
// envelope shape, owner-scoping that no request parameter can widen, and — the
|
||||
// security one — that NO secret material ever appears in a response body.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo"
|
||||
|
||||
// Distinctive secret sentinels: if any of these strings appears in ANY response
|
||||
// body, redaction failed and a real credential leaked.
|
||||
const (
|
||||
secretUserHash = "$argon2id$SENTINEL_USER_PW_HASH"
|
||||
secretOrgMaster = "SENTINEL_ORG_MASTER_PW"
|
||||
secretAppClient = "SENTINEL_APP_CLIENT_SECRET"
|
||||
secretProvClient = "SENTINEL_PROVIDER_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "compat.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Trust anchor (admin-owned RS256 signing cert = JWKS kid).
|
||||
seedCert(t, db, "admin", signingKid, pemOf(t, key))
|
||||
|
||||
// Principals across two orgs: a SuperAdmin, an org-admin, a regular user.
|
||||
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
|
||||
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
|
||||
|
||||
// Secret-bearing rows: every one carries a sentinel that must never surface.
|
||||
// users already seeded carry a password hash sentinel (set in seedUser).
|
||||
seedOrg(t, db, "hanzo") // Owner="admin", Name="hanzo", MasterPassword sentinel
|
||||
seedApp(t, db, "hanzo-console") // Owner="admin", ClientSecret sentinel
|
||||
seedProvider(t, db, "provider-gh") // Owner="admin", ClientSecret sentinel
|
||||
|
||||
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = signingKid
|
||||
s, err := tok.SignedString(h.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// get issues a GET through the real router and returns (status, rawBody).
|
||||
func (h *harness) get(t *testing.T, path, bearer string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// envelope is the v1 Response shape the clients parse.
|
||||
type envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Data2 json.RawMessage `json:"data2"`
|
||||
}
|
||||
|
||||
// ---- assertions ------------------------------------------------------------
|
||||
|
||||
func TestGetUsers_super_envelopeAndNoSecretLeak(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
t.Fatalf("body is not the v1 envelope: %v; body=%s", err, body)
|
||||
}
|
||||
if env.Status != "ok" {
|
||||
t.Fatalf("status field = %q, want ok", env.Status)
|
||||
}
|
||||
// SuperAdmin, no owner filter → every user across every org (4 seeded).
|
||||
if len(env.Data) != 4 {
|
||||
t.Fatalf("super get-users returned %d users, want 4", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_paged_data2IsTotal(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, body := h.get(t, "/v1/iam/get-users?p=1&pageSize=2", h.token(t, "admin/root"))
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
t.Fatalf("not the v1 envelope: %v", err)
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("page 1 pageSize 2 returned %d rows, want 2", len(env.Data))
|
||||
}
|
||||
// data2 carries the FULL owner-scoped total (4), not the page length.
|
||||
var total int
|
||||
if err := json.Unmarshal(env.Data2, &total); err != nil {
|
||||
t.Fatalf("data2 is not an int total: %v (data2=%s)", err, env.Data2)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Fatalf("data2 total = %d, want 4", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_unpaged_hasNoData2(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
|
||||
// v1 omits data2 entirely when the list is not paginated.
|
||||
if strings.Contains(body, "\"data2\"") {
|
||||
t.Fatalf("unpaged list must omit data2; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrganizations_super_listsAll_masked(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-organizations", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d; body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
// The masked org keeps its "***" sentinel, proving Mask ran (not the raw pw).
|
||||
if !strings.Contains(body, "***") {
|
||||
t.Fatalf("expected the masked '***' marker in the org list; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications_super_noClientSecret(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-applications", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetProviders_super_noClientSecret(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-providers", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetUser_byId_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// The the legacy surface `?id=<owner>/<name>` shape — resolved by authz.ReadTarget.
|
||||
status, body := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
if !strings.Contains(body, "alice") {
|
||||
t.Fatalf("get-user?id=hanzo/alice did not return alice; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_orgAdmin_scopedToOwnOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// An org-admin MUST pass its own owner (the Guard denies an empty owner for a
|
||||
// non-super); it then sees only its org's users.
|
||||
status, body := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/boss"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
var env envelope
|
||||
_ = json.Unmarshal([]byte(body), &env)
|
||||
if len(env.Data) != 2 { // hanzo/boss + hanzo/alice, never orgb/bob
|
||||
t.Fatalf("org-admin get-users?owner=hanzo returned %d, want 2 (own org only)", len(env.Data))
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetUsers_orgAdmin_crossTenantDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// hanzo's admin cannot list orgb's users — the Guard refuses a foreign owner.
|
||||
status, _ := h.get(t, "/v1/iam/get-users?owner=orgb", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant get-users status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_byId_crossTenantDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// The `?id=` fallback must not open a cross-tenant hole: hanzo's admin naming
|
||||
// orgb/bob is refused at the Guard, exactly as the ?owner= form is.
|
||||
status, _ := h.get(t, "/v1/iam/get-user?id=orgb/bob", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant get-user?id=orgb/bob status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_regularUser_cannotList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// A non-admin user may not enumerate its org's users (the self-service rule is
|
||||
// a single-record read, never a list).
|
||||
status, _ := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/alice"))
|
||||
if status != 403 {
|
||||
t.Fatalf("regular-user get-users status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications_nonSuper_deniedOnPlatformOwned(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// Applications are platform-owned (Owner "admin"); a non-super gets a safe 403
|
||||
// at the Guard, never another tenant's app rows.
|
||||
status, _ := h.get(t, "/v1/iam/get-applications", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("non-super get-applications status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatAliases_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// No bearer → the Guard fails closed (compat is registered after the Guard).
|
||||
if status, _ := h.get(t, "/v1/iam/get-users", ""); status != 401 {
|
||||
t.Fatalf("unauthenticated get-users status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecretLeak fails if any seeded secret sentinel appears in the body —
|
||||
// the single most important property of the whole layer.
|
||||
func assertNoSecretLeak(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{secretUserHash, secretOrgMaster, secretAppClient, secretProvClient} {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Fatalf("SECRET LEAK: %q appeared in a response body:\n%s", secret, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seed helpers ----------------------------------------------------------
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin = admin
|
||||
u.PasswordHash = secretUserHash // the sentinel that must never surface
|
||||
u.PasswordType = "argon2id"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrg(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", name // orgs are platform-owned
|
||||
o.MasterPassword = secretOrgMaster
|
||||
o.SetId("admin/" + name)
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed org: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedApp(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "admin", name
|
||||
a.Organization = "hanzo"
|
||||
a.ClientSecret = secretAppClient
|
||||
a.SetId("admin/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedProvider(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = "admin", name
|
||||
p.ClientSecret = secretProvClient
|
||||
p.SetId("admin/" + name)
|
||||
if err := p.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// 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 (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 (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"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
resolverApp = "hanzo-cloud" // admin-owned service app that holds CapKeyResolve
|
||||
otherApp = "hanzo-noresolve" // admin-owned app WITHOUT the capability
|
||||
svcSecret = "resolver-secret"
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// keyEnv decodes the single-object get-user envelope.
|
||||
type keyEnv struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getBasic drives a get through the real router authenticating as a confidential
|
||||
// client (client_secret_basic) — how cloud's key resolver authenticates to IAM.
|
||||
func (h *harness) getBasic(t *testing.T, path, clientID, secret string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
req.SetBasicAuth(clientID, secret)
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// 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)
|
||||
seedClientApp(t, h.db, otherApp, svcSecret)
|
||||
|
||||
u := orm.New[schema.User](h.db)
|
||||
u.Owner, u.Name, u.Email = "hanzo", "keyuser", "keyuser@hanzo.ai"
|
||||
u.IsAdmin = true
|
||||
u.AccessKey = userRowKey
|
||||
u.AccessSecret = projSK // a secret half on the user row too — must never surface
|
||||
u.AccessSecretHash = keyUserSecretHash
|
||||
u.PasswordHash = secretUserHash
|
||||
u.SetId("hanzo/keyuser")
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed key user: %v", err)
|
||||
}
|
||||
|
||||
k := orm.New[schema.Key](h.db)
|
||||
k.Owner, k.Name, k.User = "hanzo", "keyuser-key", "hanzo/keyuser"
|
||||
k.AccessKey, k.AccessSecret = projPK, projSK
|
||||
k.SetId("hanzo/keyuser-key")
|
||||
if err := k.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed key: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("IAM_KEY_RESOLVE_APPS", resolverApp)
|
||||
}
|
||||
|
||||
func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "admin", name // admin-owned → the CapKeyResolve owner-pin holds
|
||||
a.Organization = "hanzo"
|
||||
a.ClientId = name
|
||||
a.ClientSecret = secret
|
||||
a.SetId("admin/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed client app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestGetUserByAccessKey_ResolvesSecretsRefusesPublishable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key string }{
|
||||
{"sk confidential half", projSK},
|
||||
} {
|
||||
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
|
||||
if status != 200 {
|
||||
t.Fatalf("%s: status=%d body=%s", tc.name, status, body)
|
||||
}
|
||||
var e keyEnv
|
||||
if err := json.Unmarshal([]byte(body), &e); err != nil {
|
||||
t.Fatalf("%s: envelope: %v body=%s", tc.name, err, body)
|
||||
}
|
||||
if e.Status != "ok" {
|
||||
t.Fatalf("%s: status=%q body=%s", tc.name, e.Status, body)
|
||||
}
|
||||
if e.Data.Owner != "hanzo" || e.Data.Name != "keyuser" ||
|
||||
e.Data.Email != "keyuser@hanzo.ai" || !e.Data.IsAdmin {
|
||||
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
|
||||
// (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The PUBLIC pk- publishable half is WRITE-ONLY: get-user?accessKey REFUSES it, even
|
||||
// to the cap-holding service caller, so a public key never becomes a read principal.
|
||||
status, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+projPK, resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("publishable pk- via get-user?accessKey: status=%d env=%+v — a pk- must never resolve to a principal", status, e)
|
||||
}
|
||||
if strings.Contains(body, "keyuser") {
|
||||
t.Fatalf("publishable pk- leaked the principal identity: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestGetUserByAccessKey_CrossTenantForgeryDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
// admin/root already exists in the harness (a SuperAdmin). Plant a Key in
|
||||
// "attackerOrg" whose User names it, with a KNOWN secret. Seeded directly (the
|
||||
// write-side gate would also reject it via the API).
|
||||
k := orm.New[schema.Key](h.db)
|
||||
k.Owner, k.Name, k.User = "attackerOrg", "forge", "admin/root"
|
||||
k.AccessKey, k.AccessSecret = "pk-live-FORGE", "sk-live-FORGE"
|
||||
k.SetId("attackerOrg/forge")
|
||||
if err := k.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed forged key: %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{"pk-live-FORGE", "sk-live-FORGE"} {
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+key, resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("FORGERY resolved via %q: env=%+v body=%s", key, e, body)
|
||||
}
|
||||
if strings.Contains(body, "\"admin\"") || strings.Contains(body, "\"root\"") {
|
||||
t.Fatalf("FORGERY leaked the SuperAdmin identity via %q: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A caller WITHOUT the capability is refused with v1's verbatim message, whether it
|
||||
// is an app not on the allowlist or (implicitly) a human — never a resolved user.
|
||||
func TestGetUserByAccessKey_NonCapDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
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" {
|
||||
t.Fatalf("non-cap resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
|
||||
}
|
||||
if strings.Contains(body, "keyuser") {
|
||||
t.Fatalf("non-cap caller learned the principal: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key resolves to the not-exist envelope — fail closed, no principal.
|
||||
func TestGetUserByAccessKey_UnknownKeyNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey=hk-live-NOSUCHKEY", resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("unknown key env=%+v, want error 'the entity does not exist'", e)
|
||||
}
|
||||
}
|
||||
|
||||
// An EMPTY accessKey does not trigger key resolution — it falls through to the
|
||||
// ordinary owner/name read, which a SuperAdmin serves as before.
|
||||
func TestGetUserByAccessKey_EmptyFallsThrough(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
// Empty accessKey + an id: the handler must take the owner/name path, not the key
|
||||
// path (which would demand CapKeyResolve the human super does not hold as an app).
|
||||
status, body := h.get(t, "/v1/iam/get-user?accessKey=&id=hanzo/alice", h.token(t, "admin/root"))
|
||||
if status != 200 || !strings.Contains(body, "alice") {
|
||||
t.Fatalf("empty accessKey did not fall through to owner/name read: status=%d body=%s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal REASON reaches the wire while the human sentence stays uniform.
|
||||
//
|
||||
// "the entity does not exist" is IAM's generic answer, and cloud rendered it verbatim
|
||||
// to users: a holder whose key had been revoked was told their entity was gone and
|
||||
// went looking for a deleted organization instead of minting a new key. The prose is
|
||||
// deliberately unchanged — nothing that reads `msg` can tell the causes apart — and
|
||||
// the machine-readable `code` carries the reason to the confidential app that already
|
||||
// passed CapKeyResolve to get here.
|
||||
func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key, wantCode string }{
|
||||
{"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_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
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("%s: env=%+v — the human sentence must stay uniform", tc.name, e)
|
||||
}
|
||||
if e.Code != tc.wantCode {
|
||||
t.Errorf("%s: code = %q, want %q", tc.name, e.Code, tc.wantCode)
|
||||
}
|
||||
// The credential must never be echoed back, in any field.
|
||||
if strings.Contains(body, tc.key) {
|
||||
t.Errorf("%s: the refusal echoed the presented key: %s", tc.name, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AUTH refusal is not a key reason. A caller that fails the CapKeyResolve gate
|
||||
// gets the unauthorized envelope and NO code at all — so a non-cap caller can never
|
||||
// use `code` as an existence oracle for keys it may not resolve.
|
||||
func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
_, 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 != "" {
|
||||
t.Fatalf("non-cap caller env=%+v, want an error with NO code", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// resolve-key is the WRITE-ONLY ingest door and the exact DUAL of get-user?accessKey:
|
||||
// where that verb turns a SECRET key into a principal (for cloud's identity boundary),
|
||||
// this one turns a PUBLIC publishable pk- into just the ORG that holds it (for cloud's
|
||||
// ingest boundary). The two live side by side because they are the same shape — a
|
||||
// confidential service caller resolving an opaque key — split by the one property that
|
||||
// makes a publishable key safe to ship in client JS: a pk- yields an org and NOTHING
|
||||
// else, never a user, so it can never become a read grant.
|
||||
|
||||
// resolveResponse is the ORG-ONLY projection: the tenant a publishable key belongs to
|
||||
// and its write-only scope, and no more. It carries no user/name/email/admin — no
|
||||
// principal — so resolving a pk- discloses only WHICH org, never WHO.
|
||||
type resolveResponse struct {
|
||||
Org string `json:"org"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// resolveKeyHandler answers which organization a PUBLISHABLE key belongs to —
|
||||
// what a service of yours calls to attribute a request that arrived carrying a
|
||||
// key shipped in a browser.
|
||||
//
|
||||
// It names an organization and never a person: no path through it can load or
|
||||
// return a user, so a key you put in client code cannot become a way to learn
|
||||
// who anyone is. A key that is expired, secret rather than publishable, or
|
||||
// simply unknown all answer with the same sentence, and with a `code` saying
|
||||
// which of those it was. Only a confidential service that already proved it may
|
||||
// resolve keys at all ever reads that code — there is no anonymous caller here
|
||||
// to probe for which keys exist — and telling it apart is what lets the holder
|
||||
// be told to re-mint an expired key instead of hunting a configuration error.
|
||||
func resolveKeyHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
p, ok := authz.From(ctx)
|
||||
if !ok || p.App == "" || !authz.Allowed(p, authz.CapPublishableResolve) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
k, err := store.PublishableKeyByAccessKey(ctx, db, c.Query("accessKey"), time.Now())
|
||||
if err != nil {
|
||||
// Not found, not a pk-, not publishable, expired, or a store error — one
|
||||
// envelope, and `code` distinguishes them for the confidential app that
|
||||
// already passed CapPublishableResolve above. A store fault yields no
|
||||
// reason at all (store.Reason returns ""), so infrastructure trouble is
|
||||
// never reported to the holder as a bad key.
|
||||
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
|
||||
}
|
||||
return httpx.Ok(c, resolveResponse{Org: k.Owner, Scope: k.Scope})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
// resolve-key: the WRITE-ONLY ingest door and dual of get-user?accessKey. These tests
|
||||
// drive the REAL mounted router (routes.Route: the authz Guard authenticates the
|
||||
// confidential client, resolve-key is handler-authorized and cap-gated) and prove the
|
||||
// load-bearing property — a PUBLIC publishable pk- resolves to just an ORG, never a
|
||||
// principal, on EVERY door:
|
||||
// - resolve-key turns it into {org, scope} and nothing else (the org-only projection);
|
||||
// - a SECRET key's pk- half, an sk-, an expired/unknown key, a non-cap app, and a
|
||||
// human are all refused;
|
||||
// - and the same pk- presented to get-user?accessKey (even by a CapKeyResolve holder)
|
||||
// or as a bearer to a gated route yields NO principal.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
pubResolverApp = "hanzo-cloud-ingest" // admin-owned app holding CapPublishableResolve
|
||||
pubOtherApp = "hanzo-cloud-noingest" // admin-owned app WITHOUT the capability
|
||||
pubSecret = "ingest-resolver-secret"
|
||||
|
||||
sitePK = "pk-live-SITEKEY" // a WRITE-ONLY publishable key (Scope=publish)
|
||||
secretKeyPK = "pk-live-SERVERHALF" // the pk- half of a SECRET (default) key
|
||||
secretKeySK = "sk-live-SERVERHALF" // the sk- half of that same secret key
|
||||
)
|
||||
|
||||
// resolveEnv decodes the resolve-key envelope. Org/Scope are the org-only projection;
|
||||
// Owner/Name/Email/IsAdmin are SENTINELS — if resolve-key ever discloses a principal,
|
||||
// they surface.
|
||||
type resolveEnv struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Org string `json:"org"`
|
||||
Scope string `json:"scope"`
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// pubKeyFixtures seeds the two ingest-resolver apps, a WRITE-ONLY publishable key and a
|
||||
// SECRET key (both owned by hanzo), and arms IAM_PUBLISHABLE_RESOLVE_APPS with the
|
||||
// resolver app only.
|
||||
func pubKeyFixtures(t *testing.T, h *harness) {
|
||||
t.Helper()
|
||||
seedClientApp(t, h.db, pubResolverApp, pubSecret)
|
||||
seedClientApp(t, h.db, pubOtherApp, pubSecret)
|
||||
|
||||
// A publishable (write-only) key: Scope=publish, pk- only, no user, owned by hanzo.
|
||||
pk := orm.New[schema.Key](h.db)
|
||||
pk.Owner, pk.Name = "hanzo", "site"
|
||||
pk.Scope = schema.KeyScopePublish
|
||||
pk.AccessKey = sitePK
|
||||
pk.SetId("hanzo/site")
|
||||
if err := pk.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed publish key: %v", err)
|
||||
}
|
||||
|
||||
// A SECRET (default, Scope="") key: pk- + sk-, referencing hanzo/boss (a real user
|
||||
// the harness seeds). Its pk- half must NEVER resolve via resolve-key.
|
||||
sk := orm.New[schema.Key](h.db)
|
||||
sk.Owner, sk.Name, sk.User = "hanzo", "server", "hanzo/boss"
|
||||
sk.AccessKey, sk.AccessSecret = secretKeyPK, secretKeySK
|
||||
sk.SetId("hanzo/server")
|
||||
if err := sk.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed secret key: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("IAM_PUBLISHABLE_RESOLVE_APPS", pubResolverApp)
|
||||
}
|
||||
|
||||
// A publishable pk- resolves to just the ORG that holds it — org and scope, and NO
|
||||
// principal field of any kind.
|
||||
func TestResolveKey_ResolvesOrgOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
|
||||
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubResolverApp, pubSecret)
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
var e resolveEnv
|
||||
if err := json.Unmarshal([]byte(body), &e); err != nil {
|
||||
t.Fatalf("envelope: %v body=%s", err, body)
|
||||
}
|
||||
if e.Status != "ok" || e.Data.Org != "hanzo" || e.Data.Scope != schema.KeyScopePublish {
|
||||
t.Fatalf("resolve = %+v, want ok org=hanzo scope=publish; body=%s", e, body)
|
||||
}
|
||||
// ORG-ONLY: no principal field is populated, and no principal-only key appears in
|
||||
// the raw body — resolve-key discloses WHICH org, never WHO.
|
||||
if e.Data.Owner != "" || e.Data.Name != "" || e.Data.Email != "" || e.Data.IsAdmin {
|
||||
t.Fatalf("resolve-key disclosed a principal: %+v", e.Data)
|
||||
}
|
||||
for _, principalKey := range []string{`"email"`, `"isAdmin"`, `"name"`, `"owner"`} {
|
||||
if strings.Contains(body, principalKey) {
|
||||
t.Fatalf("resolve-key body carries a principal field %s: %s", principalKey, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SECRET key's pk- half (Scope != publish) and its sk- half are BOTH refused: the
|
||||
// door serves only keys explicitly minted as browser keys, and an sk- never matches the
|
||||
// pk- prefix.
|
||||
func TestResolveKey_RefusesNonPublishable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key string }{
|
||||
{"secret key's pk- half", secretKeyPK},
|
||||
{"an sk- confidential half", secretKeySK},
|
||||
{"a retired prefix", "hk-live-anything"},
|
||||
{"unknown pk-", "pk-live-NOSUCH"},
|
||||
{"empty", ""},
|
||||
} {
|
||||
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+tc.key, pubResolverApp, pubSecret)
|
||||
var e resolveEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("%s: status=%d env=%+v, want error 'the entity does not exist'", tc.name, status, e)
|
||||
}
|
||||
if e.Data.Org != "" {
|
||||
t.Fatalf("%s: leaked org %q on a refusal", tc.name, e.Data.Org)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An expired publishable key is refused — resolve-key honors only a live key.
|
||||
func TestResolveKey_RefusesExpired(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
|
||||
k := orm.New[schema.Key](h.db)
|
||||
k.Owner, k.Name = "hanzo", "expired"
|
||||
k.Scope = schema.KeyScopePublish
|
||||
k.AccessKey = "pk-live-EXPIRED"
|
||||
k.ExpireTime = "2020-01-01T00:00:00Z"
|
||||
k.SetId("hanzo/expired")
|
||||
if err := k.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed expired key: %v", err)
|
||||
}
|
||||
|
||||
_, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey=pk-live-EXPIRED", pubResolverApp, pubSecret)
|
||||
var e resolveEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Data.Org != "" {
|
||||
t.Fatalf("expired key resolved: env=%+v body=%s", e, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A confidential app WITHOUT CapPublishableResolve is refused — no org disclosed. This
|
||||
// is the least-privilege gate: holding some capability does not grant this one.
|
||||
func TestResolveKey_NonCapAppDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
|
||||
status, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubOtherApp, pubSecret)
|
||||
var e resolveEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("non-cap resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
|
||||
}
|
||||
if e.Data.Org != "" {
|
||||
t.Fatalf("non-cap caller learned the org: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A HUMAN — even a SuperAdmin bearer — is refused: key resolution is a machine-identity
|
||||
// boundary (a capability is vacuous for a non-app), so resolve-key is app-only.
|
||||
func TestResolveKey_HumanDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
|
||||
status, body := h.get(t, "/v1/iam/resolve-key?accessKey="+sitePK, h.token(t, "admin/root"))
|
||||
var e resolveEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("human (SuperAdmin) resolve status=%d env=%+v, want error auth:Unauthorized operation", status, e)
|
||||
}
|
||||
if e.Data.Org != "" {
|
||||
t.Fatalf("human caller learned the org: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// THE INVARIANT, end to end: the SAME publishable pk- that resolve-key turns into an
|
||||
// org can NEVER become a principal — not via get-user?accessKey (even for a caller that
|
||||
// holds CapKeyResolve and CAN resolve secret keys), and not as a bearer to a gated
|
||||
// route. A public key authenticates no read, anywhere.
|
||||
func TestResolveKey_PublishableNeverBecomesPrincipal(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
pubKeyFixtures(t, h)
|
||||
// Grant the resolver app BOTH capabilities: it CAN resolve secret keys to principals
|
||||
// (CapKeyResolve), yet the publishable pk- is still refused there.
|
||||
t.Setenv("IAM_KEY_RESOLVE_APPS", pubResolverApp)
|
||||
|
||||
// Control: resolve-key DOES turn the publishable pk- into an org.
|
||||
if _, body := h.getBasic(t, "/v1/iam/resolve-key?accessKey="+sitePK, pubResolverApp, pubSecret); !strings.Contains(body, `"org":"hanzo"`) {
|
||||
t.Fatalf("control: resolve-key should resolve the publishable pk- to an org: %s", body)
|
||||
}
|
||||
// Control: get-user?accessKey DOES resolve a SECRET sk- to its principal.
|
||||
if _, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+secretKeySK, pubResolverApp, pubSecret); !strings.Contains(body, "boss") {
|
||||
t.Fatalf("control: get-user?accessKey should resolve the secret sk- to its user: %s", body)
|
||||
}
|
||||
|
||||
// The publishable pk- via get-user?accessKey → NO principal (write-only), even for a
|
||||
// CapKeyResolve holder.
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+sitePK, pubResolverApp, pubSecret)
|
||||
var ke keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &ke)
|
||||
if ke.Status != "error" || ke.Msg != "the entity does not exist" {
|
||||
t.Fatalf("publishable pk- via get-user?accessKey resolved a principal: env=%+v body=%s", ke, body)
|
||||
}
|
||||
if strings.Contains(body, "hanzo/") || strings.Contains(body, `"isAdmin"`) {
|
||||
t.Fatalf("publishable pk- leaked identity via get-user: %s", body)
|
||||
}
|
||||
|
||||
// The publishable pk- presented as a BEARER to a gated route → 401 (never a
|
||||
// principal — it is not a token, and it can never become one).
|
||||
status, _ := h.get(t, "/v1/iam/keys?owner=hanzo", sitePK)
|
||||
if status != 401 {
|
||||
t.Fatalf("publishable pk- as bearer to a gated route: status=%d, want 401", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/applications"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/organizations"
|
||||
"github.com/hanzoai/iam/internal/projects"
|
||||
"github.com/hanzoai/iam/internal/providers"
|
||||
"github.com/hanzoai/iam/internal/roles"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/internal/workspaces"
|
||||
)
|
||||
|
||||
// The the legacy surface WRITE verbs (add-organization, add-user, update-user,
|
||||
// update-application) the console admin BFF hard-codes, served over the SAME entity
|
||||
// Create/Update logic as the REST surface — no CRUD is reimplemented here. Each is a
|
||||
// TYPED zip op (not a raw handler), which is what preserves authorization: the ONE
|
||||
// authz seam (app.Authorize) runs at every typed op's invoke on the DECODED input, so
|
||||
// a write alias is authorized against the exact (owner, name) it will bind — a super
|
||||
// for a platform-owned org/app, an org-admin for its own users — identical to the REST
|
||||
// twin. The result is wrapped in the casibase {status,msg,data} envelope the clients
|
||||
// parse; the data is the REDACTED entity (each Create/Update returns Mask()).
|
||||
//
|
||||
// Read verbs ride aliases.go; these are the "Writes ride a companion file" half.
|
||||
//
|
||||
// NONE of these carries an explicit operationId, and that is the rule rather than
|
||||
// an omission. A legacy verb alias delegates to the canonical op; the only thing
|
||||
// that distinguishes the two IS the address, so the address names it — zip's
|
||||
// path-derived default (post_v1_iam_update_provider). Naming them by hand
|
||||
// restated what the path already says AND collided: five of them
|
||||
// (add/update/delete-provider, update/delete-organization) claimed the same id as
|
||||
// the REST twin they delegate to, which OpenAPI forbids — one operationId, one
|
||||
// operation — so every generated client would bind whichever it read last. The
|
||||
// canonical REST op keeps the hand-picked SDK name; the alias is named for where
|
||||
// it is.
|
||||
|
||||
// routeWrites registers the the legacy surface write-verb aliases on app. Called from Route
|
||||
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
|
||||
//
|
||||
// Each registration's prose is the comment directly above it, and that comment is
|
||||
// the ONLY place it is written: zipdoc lifts it into zipdoc_gen.go, zip's spec
|
||||
// derives the summary from its first sentence, and the OpenAPI description, the
|
||||
// MCP tool and every generated client and CLI carry it from there. A
|
||||
// WithSummary("…") beside a comment saying the same thing is two places to change
|
||||
// and one to forget, so there is none — and a maintainer note in that position is
|
||||
// not a note, it is what a customer reads. Where the sentence had to say
|
||||
// something to US rather than to them ("console ScopeSwitcher", "the read rides
|
||||
// aliases.go"), it is here instead:
|
||||
//
|
||||
// - Grouping. Reads ride aliases.go; these are the writes. add-/update-/delete-
|
||||
// for organizations, users, applications, providers and roles; add-/delete-
|
||||
// only for projects and workspaces, whose reads ride
|
||||
// get-organization-projects and get-organization-workspaces.
|
||||
// - Authorization. Every one is a TYPED op, so app.Authorize runs at invoke on
|
||||
// the DECODED body — the write is authorized against the exact (owner, name)
|
||||
// it will bind, identically to its REST twin. For a project or workspace the
|
||||
// owner IS the organization, so the clause is org-admin of that org.
|
||||
func routeWrites(app *zip.App, db orm.DB) {
|
||||
orgs := organizations.NewOrganizationAPI(db)
|
||||
usersAPI := users.New(db)
|
||||
appCreate, appUpdate, appDelete := applications.Create(db), applications.Update(db), applications.Delete(db)
|
||||
rolesH := roles.New(db)
|
||||
projectsH := projects.New(db)
|
||||
workspacesH := workspaces.New(db)
|
||||
provAdd, provUpdate, provDelete := providers.Add(db), providers.Update(db), providers.Delete(db)
|
||||
|
||||
// Creates an organization — the account everything else in your directory
|
||||
// hangs from. Users, applications, roles, projects and workspaces are all
|
||||
// named inside one organization, so this is the first write in a new tenant.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/organizations. Both reach the same
|
||||
// create, so a name already taken is refused here too.
|
||||
zip.Post(app, "/v1/iam/add-organization",
|
||||
func(ctx context.Context, in *organizations.CreateOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Create(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Adds a person to your organization and, if you send a password, sets the
|
||||
// one they will sign in with. The password is hashed before it is stored and
|
||||
// is never returned to you or to anyone else.
|
||||
//
|
||||
// Usernames are checked against one rule wherever an account is created —
|
||||
// this verb, password signup, a social sign-in, or SCIM — so a name accepted
|
||||
// here is a name accepted everywhere.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/users, and it posts the user's fields at
|
||||
// the top level rather than wrapped in {user, password}.
|
||||
zip.Post(app, "/v1/iam/add-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Create(ctx, &users.CreateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Updates one of your users' profile, roles or credentials. Send a password
|
||||
// to reset it; leave it out and the current one stands.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/users/update, with the user's fields at
|
||||
// the top level rather than wrapped in {user, password}.
|
||||
zip.Post(app, "/v1/iam/update-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Update(ctx, &users.UpdateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Updates one of your applications — its display, its sign-in methods and the
|
||||
// redirect URIs it is allowed to return to. Which organization and name the
|
||||
// application has are fixed when it is created and are not editable here.
|
||||
//
|
||||
// A redirect URI you add becomes an allowed sign-in origin, so this is the
|
||||
// call that makes login work from a new host.
|
||||
//
|
||||
// The older spelling of PUT /v1/iam/application.
|
||||
zip.Post(app, "/v1/iam/update-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appUpdate(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Removes a person from your organization. Their sessions stop working and
|
||||
// the account is gone, not suspended — to keep the record and only stop
|
||||
// sign-in, update the user instead.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/users/delete.
|
||||
zip.Post(app, "/v1/iam/delete-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Delete(ctx, &users.Ref{Owner: in.Owner, Name: in.Name}))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Registers an application in your organization — one product or site your
|
||||
// people sign in to, with its own client credentials, sign-in methods and
|
||||
// allowed redirect URIs.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/application. A name already used in the
|
||||
// organization is refused rather than overwritten.
|
||||
zip.Post(app, "/v1/iam/add-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appCreate(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Deletes an application. Anyone mid-sign-in through it is turned away and
|
||||
// its client credentials stop working, so retire the integration first.
|
||||
//
|
||||
// The older spelling of DELETE /v1/iam/application.
|
||||
zip.Post(app, "/v1/iam/delete-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appDelete(ctx, &applications.ApplicationRef{Owner: in.Owner, Name: in.Name}))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Adds an identity provider your people can sign in with, or a service your
|
||||
// applications send through — a social or enterprise login, an email or SMS
|
||||
// sender, a storage or payment connector.
|
||||
//
|
||||
// A provider is configured once here and then switched on per application, so
|
||||
// several applications can share one set of credentials.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/providers.
|
||||
zip.Post(app, "/v1/iam/add-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provAdd(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Updates a provider's settings or rotates the credentials it holds. The
|
||||
// change takes effect on the next sign-in through it — sessions already
|
||||
// issued are unaffected.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/providers/update.
|
||||
zip.Post(app, "/v1/iam/update-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provUpdate(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Removes a provider. Sign-in through it stops for every application that
|
||||
// used it, so detach those applications first if they have no other method.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/providers/delete.
|
||||
zip.Post(app, "/v1/iam/delete-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) {
|
||||
return envelope(provDelete(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Creates a role — a named group of people that permissions are granted to.
|
||||
// Granting to a role rather than to each person is what keeps access correct
|
||||
// as your team changes: add someone to the role and they inherit everything
|
||||
// it can do.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/roles.
|
||||
zip.Post(app, "/v1/iam/add-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Create(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Updates a role's members or the roles it includes. Access changes for
|
||||
// everyone in it as soon as the write lands.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/roles/update.
|
||||
zip.Post(app, "/v1/iam/update-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Update(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Deletes a role. Everyone in it loses the access it carried; their accounts
|
||||
// and any other roles they hold are untouched.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/roles/delete.
|
||||
zip.Post(app, "/v1/iam/delete-role",
|
||||
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) {
|
||||
return envelope(rolesH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Creates a project inside your organization — the scope people pick between
|
||||
// when their work is separated by product or client rather than by team.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/projects. Creating one takes an
|
||||
// administrator of the owning organization.
|
||||
zip.Post(app, "/v1/iam/add-project",
|
||||
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) {
|
||||
return envelope(projectsH.Create(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Deletes a project. The people and roles in your organization are unchanged;
|
||||
// what goes is the scope itself, so anything addressed by it must move first.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/projects/delete.
|
||||
zip.Post(app, "/v1/iam/delete-project",
|
||||
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) {
|
||||
return envelope(projectsH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Creates a workspace inside your organization — the scope a team works in,
|
||||
// alongside projects rather than instead of them.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/workspaces. Creating one takes an
|
||||
// administrator of the owning organization.
|
||||
zip.Post(app, "/v1/iam/add-workspace",
|
||||
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) {
|
||||
return envelope(workspacesH.Create(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Deletes a workspace. The people and roles in your organization are
|
||||
// unchanged; what goes is the scope itself.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/workspaces/delete.
|
||||
zip.Post(app, "/v1/iam/delete-workspace",
|
||||
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) {
|
||||
return envelope(workspacesH.Delete(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Updates your organization — its display, its default settings and the
|
||||
// sign-in rules everyone in it inherits.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/organizations/update.
|
||||
zip.Post(app, "/v1/iam/update-organization",
|
||||
func(ctx context.Context, in *organizations.UpdateOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Update(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
|
||||
// Deletes an organization and everything named inside it — its users,
|
||||
// applications, roles, projects and workspaces. There is no undo, and every
|
||||
// session issued under it stops working.
|
||||
//
|
||||
// The older spelling of POST /v1/iam/organizations/delete.
|
||||
zip.Post(app, "/v1/iam/delete-organization",
|
||||
func(ctx context.Context, in *organizations.DeleteOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Delete(ctx, in))
|
||||
},
|
||||
zip.WithTags("compat"))
|
||||
}
|
||||
|
||||
// userBody is the bare-user body the the legacy surface add-user/update-user verbs post (the
|
||||
// user's fields at top level, plus an optional plaintext password), distinct from the
|
||||
// REST twin's {user,password} envelope. It embeds schema.User so the authz op-seam
|
||||
// reads the target (Owner, Name) straight off it, then the handler hands the parts to
|
||||
// the ONE users Create/Update path (which bcrypt-hashes the password — never stored
|
||||
// plaintext — and returns the redacted row).
|
||||
type userBody struct {
|
||||
schema.User
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// envelope wraps an entity Create/Update result in the casibase Response the the legacy surface
|
||||
// clients parse: {status:"ok", data:<masked entity>} on success, or a 200
|
||||
// {status:"error", msg} on a handler error (the casibase convention — clients branch
|
||||
// on status, not the HTTP code), never an HTTP error status. An authorization refusal
|
||||
// happens earlier, at the op-seam, and surfaces as a 403 the clients already handle.
|
||||
func envelope[T any](entity *T, err error) (*httpx.Response, error) {
|
||||
if err != nil {
|
||||
return &httpx.Response{Status: "error", Msg: err.Error()}, nil
|
||||
}
|
||||
return &httpx.Response{Status: "ok", Data: entity}, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package compat_test
|
||||
|
||||
// End-to-end tests for the the legacy surface WRITE verbs + the structurally-public front
|
||||
// door, driven through the REAL registered router (routes.Route installs the authz
|
||||
// Guard + Authorize seam; the front door is registered on the pre-Guard public
|
||||
// group). They assert the three write contracts a backend swap depends on:
|
||||
// the {status,ok} envelope every client parses, authorization identical to the REST
|
||||
// twin (super for platform-owned org/app; org-admin for its own users; cross-tenant
|
||||
// refused), and that no secret ever surfaces. Plus: the front-door session routes are
|
||||
// reachable WITHOUT a bearer (the portal/admin-guard call them with a cookie).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
// post issues a JSON POST through the real router and returns (status, rawBody).
|
||||
func (h *harness) post(t *testing.T, path, bearer string, body any) (int, string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b2, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b2)
|
||||
}
|
||||
|
||||
// okEnvelope decodes a body and asserts status=="ok".
|
||||
func okEnvelope(t *testing.T, status int, body string) {
|
||||
t.Helper()
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
t.Fatalf("not the v1 envelope: %v; body=%s", err, body)
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("status field = %v, want ok; body=%s", m["status"], body)
|
||||
}
|
||||
}
|
||||
|
||||
// add-organization is a platform-owned write — only a SuperAdmin may create one,
|
||||
// through the SAME organizations.Create the REST route uses. The created row is then
|
||||
// readable via the get-organization read alias.
|
||||
func TestAddOrganization_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-organization", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "acme", "displayName": "Acme"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
// It hit the real store — the org is now readable through the get alias.
|
||||
if s, rb := h.get(t, "/v1/iam/get-organization?id=admin/acme", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "acme") {
|
||||
t.Fatalf("created org not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-super is refused at the ONE authz seam (platform-owned resource → super-only),
|
||||
// exactly as the REST twin is.
|
||||
func TestAddOrganization_nonSuperForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-organization", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "admin", "name": "acme"})
|
||||
if status != 403 {
|
||||
t.Fatalf("org-admin add-organization status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// add-user: an org-admin creates a user in its OWN org through users.Create — the
|
||||
// password is bcrypt-hashed (never returned/stored plaintext) and the row comes back
|
||||
// redacted.
|
||||
func TestAddUser_orgAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "hanzo", "name": "newbie", "password": "S3cret-pw!"})
|
||||
okEnvelope(t, status, body)
|
||||
if strings.Contains(body, "S3cret-pw!") {
|
||||
t.Fatalf("add-user echoed the plaintext password: %s", body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
// Readable through the get alias (same store).
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/newbie", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "newbie") {
|
||||
t.Fatalf("created user not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-tenant create is refused: hanzo's admin cannot add a user under orgb.
|
||||
func TestAddUser_crossTenantForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "orgb", "name": "intruder", "password": "x"})
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant add-user status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// update-user overwrites from the body (legacy semantics) through users.Update; the
|
||||
// change is visible via the get alias and no secret leaks.
|
||||
func TestUpdateUser_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-user", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "hanzo", "name": "alice", "displayName": "Alice Updated"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "Alice Updated") {
|
||||
t.Fatalf("update-user not applied: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// update-application is platform-owned → super-only, through applications.Update.
|
||||
func TestUpdateApplication_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-application", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "hanzo-console", "displayName": "Console"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
// The write verbs are gated — no bearer fails closed at the Guard (they are
|
||||
// registered after it).
|
||||
func TestWriteAliases_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if status, _ := h.post(t, "/v1/iam/add-user", "", map[string]any{"owner": "hanzo", "name": "x"}); status != 401 {
|
||||
t.Fatalf("unauthenticated add-user status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 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). 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", 200},
|
||||
{"GET", "/v1/iam/whoami", 200},
|
||||
{"GET", "/v1/iam/linked-accounts", 400},
|
||||
} {
|
||||
status, body := h.get(t, tc.path, "")
|
||||
// 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 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: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C2 parity write-verb aliases (the console admin mutations) ---
|
||||
|
||||
// delete-user: a full lifecycle through the legacy verb (add → delete → gone).
|
||||
func TestDeleteUser_lifecycle(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
if s, b := h.post(t, "/v1/iam/add-user", root, map[string]any{"owner": "hanzo", "name": "tmp", "password": "x"}); s != 200 {
|
||||
t.Fatalf("add-user status=%d body=%s", s, b)
|
||||
}
|
||||
h.postAssertOK(t, "/v1/iam/delete-user", root, map[string]any{"owner": "hanzo", "name": "tmp"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/tmp", root); s == 200 && strings.Contains(rb, "\"name\":\"tmp\"") {
|
||||
t.Fatalf("user still present after delete-user: %s", rb)
|
||||
}
|
||||
}
|
||||
|
||||
// add-provider is platform-owned — only a SuperAdmin creates one, over the SAME
|
||||
// providers.Add the REST route uses.
|
||||
func TestAddProvider_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
h.postAssertOK(t, "/v1/iam/add-provider", root,
|
||||
map[string]any{"owner": "admin", "name": "provider-test", "category": "OAuth", "type": "GitHub"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-provider?id=admin/provider-test", root); s != 200 || !strings.Contains(rb, "provider-test") {
|
||||
t.Fatalf("get-provider after add: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// add-provider by a non-super is refused (platform-owned write).
|
||||
func TestAddProvider_nonSuperForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
s, _ := h.post(t, "/v1/iam/add-provider", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "admin", "name": "evil", "category": "OAuth", "type": "GitHub"})
|
||||
if s != 403 {
|
||||
t.Fatalf("non-super add-provider status=%d, want 403", s)
|
||||
}
|
||||
}
|
||||
|
||||
// add-role is tenant-owned — an org-admin creates one in its OWN org.
|
||||
func TestAddRole_orgAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
h.postAssertOK(t, "/v1/iam/add-role", boss,
|
||||
map[string]any{"owner": "hanzo", "name": "editors", "displayName": "Editors"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-role?id=hanzo/editors", boss); s != 200 || !strings.Contains(rb, "editors") {
|
||||
t.Fatalf("get-role after add: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// update-organization is platform-owned — SuperAdmin only.
|
||||
func TestUpdateOrganization_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
h.postAssertOK(t, "/v1/iam/update-organization", root,
|
||||
map[string]any{"owner": "admin", "name": "hanzo", "displayName": "Hanzo Updated"})
|
||||
}
|
||||
|
||||
// postAssertOK posts and asserts the {status:ok} envelope.
|
||||
func (h *harness) postAssertOK(t *testing.T, path, bearer string, body any) {
|
||||
s, b := h.post(t, path, bearer, body)
|
||||
okEnvelope(t, s, b)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/get-application", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-applications", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-cert", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-certs", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-global-users", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-invitations", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-organization", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-organization-projects", zip.Doc{
|
||||
Description: "Returns one organization's projects — what a scope switcher\nlists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-organization-workspaces", zip.Doc{
|
||||
Description: "Returns one organization's workspaces — what a scope\nswitcher lists so somebody can move between them.\n\nYou see your own organization and no other, whatever the request asks for.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-organizations", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-permission", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-permissions", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-provider", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-providers", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-records", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-role", zip.Doc{
|
||||
Description: "Reads one record — the older spelling of the single reads on the\nREST surface, over the same data and the same permissions.\n\nSecrets are stripped. Naming a record in another organization does not reach\nit, however the request spells it.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-roles", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-user", zip.Doc{
|
||||
Description: "Reads one person, two ways.\n\nName them and it is an ordinary read, with secrets stripped. Or hand it a\nSECRET API key and it answers with the person that key belongs to — how a\nservice of yours turns a credential on an incoming request into an identity.\n\nA publishable key resolves to nobody here, deliberately: it is safe to ship in\na browser precisely because it names an organization and never a person.\n\nget-user is handler-authorized (authz.handlerAuthorizedExact) because the key\nvariant carries no owner/name for the Guard to authorize; so the owner/name\nvariant reinstates the SAME read authorization the Guard applies, through the ONE\npolicy function (authz.Can) — identical behavior, a cross-tenant or non-self read\nstill refused 403 — then reuses the generic getHandler verbatim for resolution and\nredaction. No authz and no CRUD is reimplemented.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/get-users", zip.Doc{
|
||||
Description: "Lists one kind of record in your organization — the older spelling\nof the collection reads on the REST surface, over the same data and the same\npermissions.\n\nSecrets are stripped from every row. Send both a page number and a page size to\npage, and the total comes back alongside; send neither and you get the whole\nset. You see your own organization and no other, whatever the request asks for.\n\nScoping note (intentional, fail-closed): iam's ownership model is mixed —\nusers/roles/permissions are owned by their tenant org, while organizations/\napplications/providers/certs are platform-owned (Owner \"admin\"). A SuperAdmin\n(Scope → the requested owner, empty = all) therefore lists every entity, which\nis the console-admin path. A non-super is pinned by Scope to its own org, so it\nlists its tenant-owned entities correctly and is refused the platform-owned\nlists at the Guard (owner \"\" or \"admin\" both deny) — a safe 403, never another\ntenant's rows. Non-super, membership-scoped views of the platform-owned\nentities (e.g. an org console's own app list keyed on Application.Organization)\nare a separate, additive surface, not a silent behavior of this generic lister.",
|
||||
})
|
||||
zip.Describe("GET /v1/iam/resolve-key", zip.Doc{
|
||||
Description: "Answers which organization a PUBLISHABLE key belongs to —\nwhat a service of yours calls to attribute a request that arrived carrying a\nkey shipped in a browser.\n\nIt names an organization and never a person: no path through it can load or\nreturn a user, so a key you put in client code cannot become a way to learn\nwho anyone is. A key that is expired, secret rather than publishable, or\nsimply unknown all answer with the same sentence, and with a `code` saying\nwhich of those it was. Only a confidential service that already proved it may\nresolve keys at all ever reads that code — there is no anonymous caller here\nto probe for which keys exist — and telling it apart is what lets the holder\nbe told to re-mint an expired key instead of hunting a configuration error.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-application", zip.Doc{
|
||||
Description: "Registers an application in your organization — one product or site your\npeople sign in to, with its own client credentials, sign-in methods and\nallowed redirect URIs.\n\nThe older spelling of POST /v1/iam/application. A name already used in the\norganization is refused rather than overwritten.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-organization", zip.Doc{
|
||||
Description: "Creates an organization — the account everything else in your directory\nhangs from. Users, applications, roles, projects and workspaces are all\nnamed inside one organization, so this is the first write in a new tenant.\n\nThe older spelling of POST /v1/iam/organizations. Both reach the same\ncreate, so a name already taken is refused here too.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-project", zip.Doc{
|
||||
Description: "Creates a project inside your organization — the scope people pick between\nwhen their work is separated by product or client rather than by team.\n\nThe older spelling of POST /v1/iam/projects. Creating one takes an\nadministrator of the owning organization.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-provider", zip.Doc{
|
||||
Description: "Adds an identity provider your people can sign in with, or a service your\napplications send through — a social or enterprise login, an email or SMS\nsender, a storage or payment connector.\n\nA provider is configured once here and then switched on per application, so\nseveral applications can share one set of credentials.\n\nThe older spelling of POST /v1/iam/providers.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-role", zip.Doc{
|
||||
Description: "Creates a role — a named group of people that permissions are granted to.\nGranting to a role rather than to each person is what keeps access correct\nas your team changes: add someone to the role and they inherit everything\nit can do.\n\nThe older spelling of POST /v1/iam/roles.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-user", zip.Doc{
|
||||
Description: "Adds a person to your organization and, if you send a password, sets the\none they will sign in with. The password is hashed before it is stored and\nis never returned to you or to anyone else.\n\nUsernames are checked against one rule wherever an account is created —\nthis verb, password signup, a social sign-in, or SCIM — so a name accepted\nhere is a name accepted everywhere.\n\nThe older spelling of POST /v1/iam/users, and it posts the user's fields at\nthe top level rather than wrapped in {user, password}.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
|
||||
"Permission.createdTime": "Descriptive metadata.",
|
||||
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
|
||||
"Permission.owner": "Identity — the (owner, name) natural key.",
|
||||
"Permission.submitter": "Submission / approval workflow.",
|
||||
"Permission.users": "Subjects the grant is evaluated for.",
|
||||
"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.",
|
||||
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
|
||||
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
|
||||
"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 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.",
|
||||
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
|
||||
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/add-workspace", zip.Doc{
|
||||
Description: "Creates a workspace inside your organization — the scope a team works in,\nalongside projects rather than instead of them.\n\nThe older spelling of POST /v1/iam/workspaces. Creating one takes an\nadministrator of the owning organization.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-application", zip.Doc{
|
||||
Description: "Deletes an application. Anyone mid-sign-in through it is turned away and\nits client credentials stop working, so retire the integration first.\n\nThe older spelling of DELETE /v1/iam/application.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-organization", zip.Doc{
|
||||
Description: "Deletes an organization and everything named inside it — its users,\napplications, roles, projects and workspaces. There is no undo, and every\nsession issued under it stops working.\n\nThe older spelling of POST /v1/iam/organizations/delete.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-project", zip.Doc{
|
||||
Description: "Deletes a project. The people and roles in your organization are unchanged;\nwhat goes is the scope itself, so anything addressed by it must move first.\n\nThe older spelling of POST /v1/iam/projects/delete.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-provider", zip.Doc{
|
||||
Description: "Removes a provider. Sign-in through it stops for every application that\nused it, so detach those applications first if they have no other method.\n\nThe older spelling of POST /v1/iam/providers/delete.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-role", zip.Doc{
|
||||
Description: "Deletes a role. Everyone in it loses the access it carried; their accounts\nand any other roles they hold are untouched.\n\nThe older spelling of POST /v1/iam/roles/delete.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-user", zip.Doc{
|
||||
Description: "Removes a person from your organization. Their sessions stop working and\nthe account is gone, not suspended — to keep the record and only stop\nsign-in, update the user instead.\n\nThe older spelling of POST /v1/iam/users/delete.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
|
||||
"Permission.createdTime": "Descriptive metadata.",
|
||||
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
|
||||
"Permission.owner": "Identity — the (owner, name) natural key.",
|
||||
"Permission.submitter": "Submission / approval workflow.",
|
||||
"Permission.users": "Subjects the grant is evaluated for.",
|
||||
"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.",
|
||||
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
|
||||
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
|
||||
"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 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.",
|
||||
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
|
||||
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-workspace", zip.Doc{
|
||||
Description: "Deletes a workspace. The people and roles in your organization are\nunchanged; what goes is the scope itself.\n\nThe older spelling of POST /v1/iam/workspaces/delete.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/update-application", zip.Doc{
|
||||
Description: "Updates one of your applications — its display, its sign-in methods and the\nredirect URIs it is allowed to return to. Which organization and name the\napplication has are fixed when it is created and are not editable here.\n\nA redirect URI you add becomes an allowed sign-in origin, so this is the\ncall that makes login work from a new host.\n\nThe older spelling of PUT /v1/iam/application.",
|
||||
Fields: map[string]string{
|
||||
"Application.clientId": "ClientId is the OAuth2/OIDC client identifier and the GLOBAL key every\nconfidential-client resolver authenticates against (store.GetApplicationByClientId,\nthe mint gates, Basic auth). It MUST be globally unique across ALL owners — a\ncollision would let one app shadow another at that key. This store persists each\nentity as a JSON document in a shared table, so there is no per-field column to\ncarry a DB UNIQUE index; uniqueness is enforced at the write in\napplications.Create/Update (ensureClientIdUnique), exactly as the (owner,name)\nnatural key is, and store.GetApplicationByClientId resolves admin-preferring as\ndefense-in-depth.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Application].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Cert].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/update-organization", zip.Doc{
|
||||
Description: "Updates your organization — its display, its default settings and the\nsign-in rules everyone in it inherits.\n\nThe older spelling of POST /v1/iam/organizations/update.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Organization].id": "Persisted fields",
|
||||
"Organization.failedSigninLimit": "Per-organization signin throttle. Zero means \"inherit the application\ndefault\"; a non-zero value overrides it. Safe bounds are clamped by the\nresource service before persistence.",
|
||||
"Organization.founder": "Founder is the stable storage id of the identity that provisioned this org\n(self-service onboarding). It is the resume token that makes provisioning\nconverge on a backend where each write autocommits independently (no\ntransaction rollback): after a partial failure that created the org but did\nnot move the founder in, a retry recognises the org as the founder's own and\ncompletes it, instead of refusing it as \"already taken\". It also fences the\norg to ONE tenant — a different identity can never complete or join it.",
|
||||
"Organization.orgBalance": "Balance fields are read-only mirrors; authoritative balances live in\nCommerce (billing.hanzo.ai). Carried for field-complete v1 parity.",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/update-provider", zip.Doc{
|
||||
Description: "Updates a provider's settings or rotates the credentials it holds. The\nchange takes effect on the next sign-in through it — sessions already\nissued are unaffected.\n\nThe older spelling of POST /v1/iam/providers/update.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Provider].id": "Persisted fields",
|
||||
"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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/update-role", zip.Doc{
|
||||
Description: "Updates a role's members or the roles it includes. Access changes for\neveryone in it as soon as the write lands.\n\nThe older spelling of POST /v1/iam/roles/update.",
|
||||
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.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/update-user", zip.Doc{
|
||||
Description: "Updates one of your users' profile, roles or credentials. Send a password\nto reset it; leave it out and the current one stands.\n\nThe older spelling of POST /v1/iam/users/update, with the user's fields at\nthe top level rather than wrapped in {user, password}.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Permission].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Role].id": "Persisted fields",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.User].id": "Persisted fields",
|
||||
"Permission.createdTime": "Descriptive metadata.",
|
||||
"Permission.model": "Authorization model, targets, and decision. AuthzModel carries the v1\n`model` column (the named authz model); it is not the Go identifier\n`Model` because that name is taken by the embedded orm.Model[Permission]\nmixin. The HTTP contract is unchanged — json:\"model\".",
|
||||
"Permission.owner": "Identity — the (owner, name) natural key.",
|
||||
"Permission.submitter": "Submission / approval workflow.",
|
||||
"Permission.users": "Subjects the grant is evaluated for.",
|
||||
"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.",
|
||||
"User.accessKey": "API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are\nbearer material. AccessSecretHash MUST persist (orm stores via JSON; a\njson:\"-\" field is never saved), so it carries a real json tag and the\nhandler's redact() strips it (and AccessSecret + the token fields) before\nresponding.",
|
||||
"User.balance": "Balance mirrors v1 for lossless migration but is authoritative in\nCommerce (billing.hanzo.ai), not here — do not write it from IAM.",
|
||||
"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 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.",
|
||||
"User.roles": "Authorization attachments. Roles and Permissions are computed on read\nfrom the authz store and carried here for API parity with v1.",
|
||||
"User.webauthnCredentials": "Multi-factor authentication. TotpSecret and RecoveryCodes are secret\nverify-only material — the handler strips them from every response.\nWebauthnCredentials is carried as raw JSON here for lossless migration;\nthe typed passkey model is the sibling WebauthnCredential entity.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// 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, 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.
|
||||
// That is cross-origin, so without an Access-Control-Allow-Origin header the
|
||||
// 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.
|
||||
//
|
||||
// Only the endpoints a browser legitimately calls cross-origin are opened.
|
||||
//
|
||||
// # 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"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// here, so without these a registered app either cannot render its own org
|
||||
// switcher or has to route the read through its own backend — a second copy
|
||||
// of an identity read, which is how backends end up re-implementing IAM.
|
||||
//
|
||||
// Opening a path here does NOT open the data: the Guard still requires a
|
||||
// verified bearer and authorizes the exact (owner, name) addressed, so a
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// caller's principal, so the browser can only do what that user could
|
||||
// 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": 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,
|
||||
// cached because the answer changes only when an application does, and the
|
||||
// alternative is a full scan on every preflight.
|
||||
type registry struct {
|
||||
db orm.DB
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
origins map[string]bool
|
||||
loaded time.Time
|
||||
}
|
||||
|
||||
func (r *registry) allowed(ctx context.Context, origin string) bool {
|
||||
r.mu.RLock()
|
||||
fresh := time.Since(r.loaded) < r.ttl && r.origins != nil
|
||||
if fresh {
|
||||
ok := r.origins[origin]
|
||||
r.mu.RUnlock()
|
||||
// A hit is authoritative. A miss on a fresh cache is only authoritative
|
||||
// once we know the cache is not stale — it is, so the miss stands.
|
||||
return ok
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
set := load(ctx, r.db)
|
||||
if set == nil {
|
||||
// A storage error must not silently open the IdP to every origin, nor
|
||||
// permanently close it: keep whatever we had and answer from that.
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.origins[origin]
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.origins, r.loaded = set, time.Now()
|
||||
r.mu.Unlock()
|
||||
return set[origin]
|
||||
}
|
||||
|
||||
// load collects the origin of every registered redirect URI.
|
||||
func load(ctx context.Context, db orm.DB) map[string]bool {
|
||||
apps, err := orm.TypedQuery[schema.Application](db).GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool, len(apps)*2)
|
||||
for _, a := range apps {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
for _, raw := range a.RedirectUris {
|
||||
if o := originOf(raw); o != "" {
|
||||
set[o] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// originOf reduces a redirect URI to its serialized origin (scheme://host[:port]),
|
||||
// which is exactly the form a browser puts in the Origin header. Loopback and
|
||||
// custom-scheme redirects (cli/desktop clients) have no browser origin and are
|
||||
// skipped — they never send one.
|
||||
func originOf(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// 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 {
|
||||
mode := browserPaths[c.Path()]
|
||||
if mode == absent {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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") }
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cors
|
||||
|
||||
import "testing"
|
||||
|
||||
// A redirect URI reduces to exactly the string a browser sends in Origin —
|
||||
// scheme + host + port, nothing else. Getting this wrong means a registered
|
||||
// app still gets blocked, which is the bug this package exists to fix.
|
||||
func TestOriginOf_MatchesWhatABrowserSends(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"https://lux.cloud/auth/callback", "https://lux.cloud"},
|
||||
{"https://console.lux.cloud/auth/callback", "https://console.lux.cloud"},
|
||||
{"https://lux.cloud:8443/auth/callback", "https://lux.cloud:8443"}, // port is part of the origin
|
||||
{"https://lux.cloud", "https://lux.cloud"}, // no path
|
||||
{" https://lux.cloud/auth/callback ", "https://lux.cloud"}, // document whitespace
|
||||
} {
|
||||
if got := originOf(tc.in); got != tc.want {
|
||||
t.Errorf("originOf(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cli and desktop clients register loopback and custom-scheme redirects. They
|
||||
// never send an Origin, so they must not widen the allowlist — in particular a
|
||||
// deep-link scheme must never become an allowed web origin.
|
||||
func TestOriginOf_SkipsNonBrowserRedirects(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"lux://oauth/desk",
|
||||
"http://127.0.0.1/callback", // loopback IS http, but see below
|
||||
"",
|
||||
"://malformed",
|
||||
} {
|
||||
got := originOf(in)
|
||||
if in == "http://127.0.0.1/callback" {
|
||||
// Loopback is a real http origin and is returned; it is harmless
|
||||
// (no site is served there) and keeps a local dev client working.
|
||||
if got != "http://127.0.0.1" {
|
||||
t.Errorf("originOf(%q) = %q, want the loopback origin", in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("originOf(%q) = %q, want empty", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
for _, p := range []string{
|
||||
"/v1/iam/oauth/token",
|
||||
"/.well-known/openid-configuration",
|
||||
"/v1/iam/.well-known/jwks",
|
||||
"/v1/iam/oauth/userinfo",
|
||||
} {
|
||||
if browserPaths[p] != bearer {
|
||||
t.Errorf("%s must be reachable cross-origin, proving itself with a Bearer", p)
|
||||
}
|
||||
}
|
||||
// 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/oauth/authorize", // a top-level redirect, not a fetch
|
||||
} {
|
||||
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) {
|
||||
set := map[string]bool{}
|
||||
for _, u := range []string{
|
||||
"https://lux.cloud/auth/callback",
|
||||
"https://www.lux.cloud/auth/callback",
|
||||
"lux://oauth/desk",
|
||||
} {
|
||||
if o := originOf(u); o != "" {
|
||||
set[o] = true
|
||||
}
|
||||
}
|
||||
if !set["https://lux.cloud"] || !set["https://www.lux.cloud"] {
|
||||
t.Errorf("registered hosts missing from the allowlist: %v", set)
|
||||
}
|
||||
if set["https://evil.example"] {
|
||||
t.Error("an unregistered origin must never be allowed")
|
||||
}
|
||||
if len(set) != 2 {
|
||||
t.Errorf("deep-link scheme leaked into the web allowlist: %v", set)
|
||||
}
|
||||
}
|
||||
|
||||
// The org surface a console reads about itself must be reachable cross-origin,
|
||||
// or a registered SPA cannot render its own org switcher and its backend ends up
|
||||
// re-implementing an identity read. These sit beside the OIDC endpoints because
|
||||
// they are the same shape: a Bearer-protected read whose ORIGIN is decided here
|
||||
// and whose PRINCIPAL is decided by the Guard.
|
||||
func TestBrowserPaths_CoverTheConsoleOrgSurface(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"/v1/iam/get-organizations",
|
||||
"/v1/iam/get-organization",
|
||||
"/v1/iam/get-users",
|
||||
"/v1/iam/get-account",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opening a path to an origin is not the same as opening the data. Anything a
|
||||
// browser never calls stays closed, so this list can only grow deliberately.
|
||||
func TestBrowserPaths_StayClosedByDefault(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"/v1/iam/users", // typed CRUD — server-to-server
|
||||
"/v1/iam/get-certs", // signing material
|
||||
"/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] != 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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.
|
||||
//
|
||||
// Why this exists: v1 stamps `argon2id` on effectively every live row (the org's
|
||||
// PasswordType is rewritten to argon2id on create/update, and UpdateUserPassword
|
||||
// stamps it per user). A bcrypt-only verifier handed an argon2id PHC string
|
||||
// returns ErrHashTooShort, so a bcrypt-only login fails 100% of real users at
|
||||
// cutover. v1 resolves per row — user.PasswordType, falling back to the
|
||||
// organization's — and dispatches to the matching manager. iam does the same.
|
||||
//
|
||||
// Hashing is argon2id ONLY (SOTA). Verify stays scheme-aware so pre-existing
|
||||
// bcrypt and v1 argon2id rows keep validating, but every NEW or updated digest
|
||||
// this package mints is argon2id — one way to hash, the strongest one. Re-hashing
|
||||
// a verified bcrypt row to argon2id (upgrade-on-login) is a separate, deliberate
|
||||
// decision, not a side effect of a read.
|
||||
package cred
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Supported password types. These are the two schemes Hanzo actually stores:
|
||||
// argon2id (every live v1 row) and bcrypt (what iam mints for new users).
|
||||
// Anything else fails CLOSED — a silent "true" on an unrecognized scheme would
|
||||
// be an auth bypass, and a silent "false" we can't explain is a support
|
||||
// nightmare, so Verify reports Unsupported distinctly.
|
||||
const (
|
||||
TypeArgon2id = "argon2id"
|
||||
TypeBcrypt = "bcrypt"
|
||||
)
|
||||
|
||||
// Resolve returns the password type for a row: the user's own, else the
|
||||
// organization's, else "" (caller decides — never guess a default, since a wrong
|
||||
// guess is either a failed login or, worse, a bypass).
|
||||
func Resolve(userType, orgType string) string {
|
||||
if userType != "" {
|
||||
return userType
|
||||
}
|
||||
return orgType
|
||||
}
|
||||
|
||||
// Supported reports whether Verify can handle this password type.
|
||||
func Supported(passwordType string) bool {
|
||||
switch passwordType {
|
||||
case TypeArgon2id, TypeBcrypt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify reports whether plaintext matches the stored digest under passwordType.
|
||||
// Both supported schemes carry their own parameters in the digest (bcrypt's
|
||||
// $2a$… and argon2id's $argon2id$v=19$… PHC string), so no external salt is
|
||||
// needed; salt is accepted for the legacy per-row salt schemes v1 also supports
|
||||
// and is currently unused.
|
||||
//
|
||||
// Fails closed: an unknown/empty type, an empty hash, or a malformed digest
|
||||
// returns false.
|
||||
func Verify(passwordType, plaintext, hashed string) bool {
|
||||
if hashed == "" || !Supported(passwordType) {
|
||||
return false
|
||||
}
|
||||
switch passwordType {
|
||||
case TypeArgon2id:
|
||||
// ComparePasswordAndHash is constant-time internally and parses the PHC
|
||||
// parameters from the digest itself; a malformed digest returns an error,
|
||||
// which we treat as "no match" (never a panic, never a pass).
|
||||
match, err := argon2id.ComparePasswordAndHash(plaintext, hashed)
|
||||
return err == nil && match
|
||||
case TypeBcrypt:
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plaintext)) == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hashParams are the argon2id cost parameters for every new digest — OWASP-aligned
|
||||
// SOTA (64 MiB memory, 2 passes, parallelism 1), tuned so a login stays well under
|
||||
// ~100ms while resisting GPU/ASIC cracking. The parameters + a per-hash random salt
|
||||
// ride INSIDE the PHC string, so Verify reads them from the digest itself — changing
|
||||
// these never invalidates an already-stored hash.
|
||||
var hashParams = &argon2id.Params{
|
||||
Memory: 64 * 1024, // 64 MiB
|
||||
Iterations: 2,
|
||||
Parallelism: 1,
|
||||
SaltLength: 16,
|
||||
KeyLength: 32,
|
||||
}
|
||||
|
||||
// Hash derives a one-way argon2id (PHC) digest from a plaintext password — the
|
||||
// SOTA scheme every new/updated Hanzo password uses, stamped TypeArgon2id. The
|
||||
// cost parameters and a per-hash random salt are embedded in the returned string,
|
||||
// so Verify needs no external salt or config. The plaintext is never logged or
|
||||
// stored; only this one-way digest is.
|
||||
func Hash(plaintext string) (string, error) {
|
||||
return argon2id.CreateHash(plaintext, hashParams)
|
||||
}
|
||||
|
||||
// ConstantTimeEqual is a small helper for comparing non-hash secrets (e.g. a
|
||||
// verification code) without leaking length/position through timing.
|
||||
func ConstantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cred
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// TestVerify_Argon2id_RealV1FormatHash is the regression for the cutover
|
||||
// blocker: every live v1 row is argon2id, and a bcrypt-only verifier fails all
|
||||
// of them. This proves iam verifies a genuine argon2id PHC digest — the exact
|
||||
// shape v1's Argon2idCredManager writes (github.com/alexedwards/argon2id,
|
||||
// DefaultParams).
|
||||
func TestVerify_Argon2id_RealV1FormatHash(t *testing.T) {
|
||||
pw := "correct horse battery staple"
|
||||
hash, err := argon2id.CreateHash(pw, argon2id.DefaultParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Sanity: it really is the PHC shape a live row carries.
|
||||
if len(hash) < 20 || hash[:9] != "$argon2id" {
|
||||
t.Fatalf("not an argon2id PHC digest: %q", hash)
|
||||
}
|
||||
if !Verify(TypeArgon2id, pw, hash) {
|
||||
t.Fatal("argon2id: correct password REJECTED — this is the cutover blocker")
|
||||
}
|
||||
if Verify(TypeArgon2id, "wrong password", hash) {
|
||||
t.Fatal("argon2id: wrong password ACCEPTED")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_BcryptStillWorks — new iam-minted users are bcrypt; don't regress.
|
||||
func TestVerify_Bcrypt(t *testing.T) {
|
||||
pw := "s3cret-pw"
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
|
||||
if !Verify(TypeBcrypt, pw, string(h)) {
|
||||
t.Fatal("bcrypt: correct password rejected")
|
||||
}
|
||||
if Verify(TypeBcrypt, "nope", string(h)) {
|
||||
t.Fatal("bcrypt: wrong password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_CrossSchemeFailsClosed — the actual bug: an argon2id digest handed
|
||||
// to the bcrypt path (or vice versa) must NOT pass, and must not panic.
|
||||
func TestVerify_CrossSchemeFailsClosed(t *testing.T) {
|
||||
pw := "x"
|
||||
argon, _ := argon2id.CreateHash(pw, argon2id.DefaultParams)
|
||||
bc, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
|
||||
|
||||
if Verify(TypeBcrypt, pw, argon) {
|
||||
t.Fatal("argon2id digest verified under bcrypt — auth bypass")
|
||||
}
|
||||
if Verify(TypeArgon2id, pw, string(bc)) {
|
||||
t.Fatal("bcrypt digest verified under argon2id — auth bypass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_FailsClosedOnGarbage — unknown type, empty hash, malformed digest.
|
||||
func TestVerify_FailsClosedOnGarbage(t *testing.T) {
|
||||
cases := []struct{ typ, pw, hash string }{
|
||||
{"", "pw", "$argon2id$v=19$whatever"}, // no type
|
||||
{"sha256-salt", "pw", "deadbeef"}, // unsupported legacy type
|
||||
{"plain", "pw", "pw"}, // plaintext scheme: refused
|
||||
{TypeArgon2id, "pw", ""}, // empty hash
|
||||
{TypeArgon2id, "pw", "not-a-phc-string"}, // malformed
|
||||
{TypeBcrypt, "pw", "$2a$garbage"}, // malformed bcrypt
|
||||
{"ARGON2ID", "pw", "$argon2id$v=19$x"}, // case-sensitive: not supported
|
||||
}
|
||||
for _, c := range cases {
|
||||
if Verify(c.typ, c.pw, c.hash) {
|
||||
t.Fatalf("verify(%q, hash=%q) returned TRUE — must fail closed", c.typ, c.hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_PerRowThenOrgFallback — v1's contract: the user's own type wins;
|
||||
// an empty user type falls back to the org's; never a hardcoded default.
|
||||
func TestResolve(t *testing.T) {
|
||||
if got := Resolve("bcrypt", "argon2id"); got != "bcrypt" {
|
||||
t.Fatalf("user type must win: got %q", got)
|
||||
}
|
||||
if got := Resolve("", "argon2id"); got != "argon2id" {
|
||||
t.Fatalf("empty user type must fall back to org: got %q", got)
|
||||
}
|
||||
if got := Resolve("", ""); got != "" {
|
||||
t.Fatalf("both empty must stay empty (caller decides), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupported(t *testing.T) {
|
||||
for _, ok := range []string{TypeArgon2id, TypeBcrypt} {
|
||||
if !Supported(ok) {
|
||||
t.Fatalf("%s must be supported", ok)
|
||||
}
|
||||
}
|
||||
for _, no := range []string{"", "plain", "salt", "sha512-salt", "md5-salt", "pbkdf2-salt"} {
|
||||
if Supported(no) {
|
||||
t.Fatalf("%q must NOT be supported (fail closed)", no)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package cred
|
||||
|
||||
import "testing"
|
||||
|
||||
// Golden vectors: PHC digests produced by **v1's own Argon2idCredManager**
|
||||
// (hanzoai/iam `cred.NewArgon2idCredManager().GetHashedPassword`, DefaultParams),
|
||||
// captured verbatim. This is the parity proof that matters — iam must verify the
|
||||
// exact bytes v1 wrote, not merely a digest iam generated itself.
|
||||
//
|
||||
// It also pins a REAL cross-version risk: v1 resolves
|
||||
// `github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387` while iam
|
||||
// pins `v1.0.0`. The PHC string is self-describing (m/t/p + salt + key), so a
|
||||
// digest from either version must verify under the other — this test is what
|
||||
// proves that, and what fails loudly if a future bump ever breaks it.
|
||||
//
|
||||
// These are throwaway TEST passwords. No live user's digest is ever committed —
|
||||
// a real hash is an offline-attackable secret and does not belong in a repo.
|
||||
|
||||
const (
|
||||
// v1 Argon2idCredManager.GetHashedPassword("golden-test-password-1", "")
|
||||
goldenV1Password = "golden-test-password-1"
|
||||
goldenV1Digest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
|
||||
)
|
||||
|
||||
// TestGolden_V1Argon2idDigestVerifies is the cutover-parity assertion: a digest
|
||||
// written by the LIVE v1 code path verifies under iam's cred.Verify.
|
||||
func TestGolden_V1Argon2idDigestVerifies(t *testing.T) {
|
||||
if !Verify(TypeArgon2id, goldenV1Password, goldenV1Digest) {
|
||||
t.Fatal("iam REJECTED a digest produced by v1's Argon2idCredManager — " +
|
||||
"credential parity is broken; every live login would fail at cutover")
|
||||
}
|
||||
if Verify(TypeArgon2id, "not-the-password", goldenV1Digest) {
|
||||
t.Fatal("wrong password ACCEPTED against the v1 golden digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGolden_V1DigestShape documents the exact PHC shape v1 emits, so a change in
|
||||
// v1's params (or a lib bump on either side) is caught here rather than in prod.
|
||||
func TestGolden_V1DigestShape(t *testing.T) {
|
||||
// $argon2id$v=19$m=65536,t=1,p=2$<salt>$<key>
|
||||
const wantPrefix = "$argon2id$v=19$m=65536,t=1,p="
|
||||
if len(goldenV1Digest) < len(wantPrefix) || goldenV1Digest[:len(wantPrefix)] != wantPrefix {
|
||||
t.Fatalf("v1 digest shape changed: %q", goldenV1Digest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGolden_ResolvedThroughRowType proves the full row→algorithm path a real
|
||||
// login takes: the row says "argon2id" (what every live v1 row says), the org
|
||||
// fallback is irrelevant, and the v1 digest verifies.
|
||||
func TestGolden_ResolvedThroughRowType(t *testing.T) {
|
||||
typ := Resolve("argon2id", "bcrypt") // user's own type must win
|
||||
if typ != TypeArgon2id {
|
||||
t.Fatalf("resolve: got %q", typ)
|
||||
}
|
||||
if !Verify(typ, goldenV1Password, goldenV1Digest) {
|
||||
t.Fatal("row-resolved argon2id failed to verify the v1 golden digest")
|
||||
}
|
||||
// And the bug that shipped: resolving to bcrypt against this digest must FAIL,
|
||||
// never pass.
|
||||
if Verify(TypeBcrypt, goldenV1Password, goldenV1Digest) {
|
||||
t.Fatal("v1 argon2id digest verified under bcrypt — auth bypass")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// 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
|
||||
// old the legacy surface IAM's clients work against iam. Unlike the per-package unit tests,
|
||||
// this chains the real flows a live client runs in sequence: OIDC discovery →
|
||||
// PKCE login → code→token → userinfo → introspect → revoke; the admin console's
|
||||
// get-account → get-organizations → get-users (the legacy compat surface); SCIM
|
||||
// 2.0 provisioning; and RFC 8693 token exchange. Every step asserts the response
|
||||
// CONTRACT the client depends on.
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
kid = "cert-hanzo"
|
||||
redirectURI = "https://console.hanzo.ai/auth/callback"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func boot(t *testing.T) *env {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "e2e.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
seedCert(t, db, key)
|
||||
// A confidential console app: password login + PKCE, in the hanzo org.
|
||||
seedApp(t, db)
|
||||
seedOrg(t, db, "admin")
|
||||
seedOrg(t, db, "hanzo")
|
||||
seedUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw", false)
|
||||
seedUser(t, db, "admin", "root", "root@hanzo.ai", "pw", true) // SuperAdmin
|
||||
|
||||
app := zip.New(zip.Config{AppName: "iam-e2e", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &env{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
// TestJourney_OIDCFlow is the full OAuth2/OIDC round trip a client SDK runs.
|
||||
func TestJourney_OIDCFlow(t *testing.T) {
|
||||
e := boot(t)
|
||||
|
||||
// 1) Discovery is self-consistent (one issuer, the endpoints a strict client pins).
|
||||
disc := e.getJSON(t, "/.well-known/openid-configuration", "")
|
||||
if disc["issuer"] == "" || disc["token_endpoint"] == "" || disc["jwks_uri"] == "" {
|
||||
t.Fatalf("discovery incomplete: %v", disc)
|
||||
}
|
||||
if disc["introspection_endpoint"] == "" || disc["revocation_endpoint"] == "" {
|
||||
t.Fatalf("discovery missing RFC 7662/7009 endpoints: %v", disc)
|
||||
}
|
||||
// RFC 8414 AS metadata served at its own well-known.
|
||||
if as := e.getJSON(t, "/.well-known/oauth-authorization-server", ""); as["issuer"] == "" {
|
||||
t.Fatalf("RFC 8414 AS metadata missing")
|
||||
}
|
||||
// 2) JWKS publishes a verification key.
|
||||
jwks := e.getJSON(t, "/v1/iam/.well-known/jwks", "")
|
||||
if keys, _ := jwks["keys"].([]any); len(keys) == 0 {
|
||||
t.Fatalf("JWKS has no keys: %v", jwks)
|
||||
}
|
||||
|
||||
// 3) PKCE login → single-use code.
|
||||
verifier := "e2e-verifier-0000000000000000000000000000000000000"
|
||||
code := e.login(t, verifier)
|
||||
|
||||
// 4) Redeem the code → access token (+ id_token on openid, refresh on offline).
|
||||
tok := e.token(t, url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"redirect_uri": {redirectURI}, "code_verifier": {verifier},
|
||||
})
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no access_token: %v", tok)
|
||||
}
|
||||
|
||||
// 5) UserInfo carries the identity + the admin-guard contract (owner, isAdmin).
|
||||
info := e.getJSON(t, "/v1/iam/oauth/userinfo", access)
|
||||
if info["sub"] != "hanzo/alice" || info["owner"] != "hanzo" {
|
||||
t.Fatalf("userinfo sub/owner wrong: %v", info)
|
||||
}
|
||||
if _, ok := info["isAdmin"]; !ok {
|
||||
t.Fatalf("userinfo missing the isAdmin claim (admin-guard contract): %v", info)
|
||||
}
|
||||
|
||||
// 6) Introspection (RFC 7662): active, with the standard claims.
|
||||
ir := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if ir["active"] != true || ir["sub"] != "hanzo/alice" {
|
||||
t.Fatalf("introspect not active/wrong sub: %v", ir)
|
||||
}
|
||||
|
||||
// 7) Revocation (RFC 7009): the token dies — introspect flips to inactive.
|
||||
e.form(t, "/v1/iam/oauth/revoke", "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if after := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}}); after["active"] != false {
|
||||
t.Fatalf("token still active after revoke: %v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_PasswordGrant_and_TokenExchange proves the two non-interactive grants
|
||||
// the console/BFF rely on.
|
||||
func TestJourney_PasswordGrant_and_TokenExchange(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
e := boot(t)
|
||||
|
||||
// Password grant → a first-party session token for alice.
|
||||
pw := e.token(t, url.Values{
|
||||
"grant_type": {"password"}, "client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"username": {"alice@hanzo.ai"}, "password": {"pw"}, "scope": {"openid profile"},
|
||||
})
|
||||
subjectToken, _ := pw["access_token"].(string)
|
||||
if subjectToken == "" {
|
||||
t.Fatalf("password grant failed: %v", pw)
|
||||
}
|
||||
|
||||
// RFC 8693 token exchange: the BFF exchanges alice's token for one scoped to a
|
||||
// downstream resource, still bound to alice.
|
||||
xe := e.token(t, url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"subject_token": {subjectToken}, "resource": {"hanzo-cloud"},
|
||||
})
|
||||
if xe["issued_token_type"] != "urn:ietf:params:oauth:token-type:access_token" || xe["access_token"] == "" {
|
||||
t.Fatalf("token exchange failed: %v", xe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_AdminConsole_LegacySurface proves the old admin console's calls work:
|
||||
// get-account (the security contract), get-organizations (OrgSwitcher), get-users.
|
||||
func TestJourney_AdminConsole_LegacySurface(t *testing.T) {
|
||||
e := boot(t)
|
||||
root := e.mint(t, "admin/root") // a SuperAdmin bearer
|
||||
|
||||
// get-account — {status:ok, data:<masked user>} with owner + isAdmin.
|
||||
acct := e.getJSON(t, "/v1/iam/get-account", root)
|
||||
if acct["status"] != "ok" {
|
||||
t.Fatalf("get-account status: %v", acct)
|
||||
}
|
||||
|
||||
// get-organizations — the OrgSwitcher workhorse; SuperAdmin sees all.
|
||||
orgs := e.getJSON(t, "/v1/iam/get-organizations", root)
|
||||
if orgs["status"] != "ok" {
|
||||
t.Fatalf("get-organizations status: %v", orgs)
|
||||
}
|
||||
if data, _ := orgs["data"].([]any); len(data) < 2 {
|
||||
t.Fatalf("get-organizations returned %d orgs, want >=2 (admin+hanzo)", len(data))
|
||||
}
|
||||
|
||||
// get-users scoped to an org — no secret leaks.
|
||||
usersBody := e.getRaw(t, "/v1/iam/get-users?owner=hanzo", root)
|
||||
if strings.Contains(usersBody, "passwordHash") || strings.Contains(usersBody, "\"password\"") {
|
||||
t.Fatalf("get-users leaked a secret: %s", usersBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_SCIMProvisioning proves the RFC-standard provisioning path an IdP uses.
|
||||
func TestJourney_SCIMProvisioning(t *testing.T) {
|
||||
e := boot(t)
|
||||
root := e.mint(t, "admin/root")
|
||||
|
||||
create := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"newhire",` +
|
||||
`"active":true,"password":"pw","urn:ietf:params:scim:schemas:extension:hanzo:2.0:User":{"owner":"hanzo"}}`
|
||||
st, body := e.req(t, "POST", "/v1/iam/scim/v2/Users", root, create, "application/scim+json")
|
||||
if st != 201 {
|
||||
t.Fatalf("SCIM create status = %d: %s", st, body)
|
||||
}
|
||||
if st, _ := e.req(t, "GET", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 200 {
|
||||
t.Fatalf("SCIM get status = %d", st)
|
||||
}
|
||||
if st, _ := e.req(t, "DELETE", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 204 {
|
||||
t.Fatalf("SCIM delete status = %d", st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- flow helpers ----
|
||||
|
||||
func (e *env) login(t *testing.T, verifier string) string {
|
||||
t.Helper()
|
||||
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": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
|
||||
if st != 200 {
|
||||
t.Fatalf("login status = %d: %s", st, resp)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(resp), &m)
|
||||
code, _ := m["data"].(string)
|
||||
if code == "" {
|
||||
t.Fatalf("login returned no code: %s", resp)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (e *env) token(t *testing.T, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
st, body := e.req(t, "POST", "/v1/iam/oauth/token", "", form.Encode(), "application/x-www-form-urlencoded")
|
||||
_ = st
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) form(t *testing.T, path, clientID, secret string, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
resp, err := testhttp.Do(e.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("form %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) req(t *testing.T, method, path, bearer, body, contentType string) (int, string) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(e.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (e *env) getJSON(t *testing.T, path, bearer string) map[string]any {
|
||||
t.Helper()
|
||||
_, body := e.req(t, "GET", path, bearer, "", "")
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) getRaw(t *testing.T, path, bearer string) string {
|
||||
t.Helper()
|
||||
_, body := e.req(t, "GET", path, bearer, "", "")
|
||||
return body
|
||||
}
|
||||
|
||||
// mint signs an RS256 bearer for sub under the seeded cert — a valid principal the
|
||||
// Guard admits (used for the compat/SCIM admin calls, which need a verified bearer
|
||||
// but not a persisted grant row).
|
||||
func (e *env) mint(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub, "iat": time.Now().Add(-time.Minute).Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString(e.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- seed helpers ----
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, key *rsa.PrivateKey) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name, c.CryptoAlgorithm = "admin", kid, "RS256"
|
||||
c.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
c.SetId("admin/" + kid)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedApp(t *testing.T, db orm.DB) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name, a.ClientId, a.ClientSecret = "admin", "hanzo-console", "hanzo-console", "top-secret"
|
||||
a.Organization, a.Cert, a.EnablePassword = "hanzo", kid, true
|
||||
a.RedirectUris = []string{redirectURI}
|
||||
a.ExpireInHours = 1
|
||||
a.SetId("admin/hanzo-console")
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrg(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", name
|
||||
o.SetId("admin/" + name)
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed org %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name, email, password string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name, u.Email, u.IsAdmin = owner, name, email, admin
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if herr != nil {
|
||||
t.Fatalf("hash: %v", herr)
|
||||
}
|
||||
u.PasswordHash, u.PasswordType = string(hash), "bcrypt"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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.
|
||||
// Internal: the core (server.Route) constructs it and hands the interface to
|
||||
// feature.RouteAll — modules never see this package, only the feature.Store seam.
|
||||
package featurestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/feature"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
type ormStore struct {
|
||||
db orm.DB
|
||||
u *users.API
|
||||
}
|
||||
|
||||
// New returns a feature.Store backed by db (the core's one identity store).
|
||||
func New(db orm.DB) feature.Store { return &ormStore{db: db, u: users.New(db)} }
|
||||
|
||||
func (s *ormStore) GetUser(ctx context.Context, owner, name string) (*model.User, error) {
|
||||
return store.GetUserByName(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
// GetUserByID resolves the seam's user id — schema.User.Id, the stable opaque
|
||||
// UUID the OIDC `sub` carries — through store.GetUserById, the ONE subject
|
||||
// resolver, so a module and the core name a user the same way.
|
||||
//
|
||||
// It must NOT be orm.Get, which keys on the orm STORAGE id: that is a different
|
||||
// value (a v2-native row's surrogate, a migrated row's "owner/name"), so the id
|
||||
// AddUser assigns would not resolve here, and the "owner/name" shape is both
|
||||
// mutable and slash-bearing — unusable as an opaque single-segment resource id.
|
||||
// Going through store also keeps the fail-closed check on a duplicated subject.
|
||||
func (s *ormStore) GetUserByID(ctx context.Context, id string) (*model.User, error) {
|
||||
return store.GetUserById(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error) {
|
||||
total, err := orm.TypedQuery[schema.User](s.db).Count(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.User](s.db).Order("Name")
|
||||
if offset > 0 {
|
||||
q = q.Offset(offset)
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
list, err := q.GetAll(ctx)
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *ormStore) AddUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Create(ctx, &users.CreateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) UpdateUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) DeleteUser(ctx context.Context, owner, name string) (bool, error) {
|
||||
out, err := s.u.Delete(ctx, &users.Ref{Owner: owner, Name: name})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out.Deleted, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) GetApplication(ctx context.Context, id string) (*model.Application, error) {
|
||||
if app, err := store.GetApplicationByName(ctx, s.db, "admin", id); err == nil && app != nil {
|
||||
return app, nil
|
||||
}
|
||||
return store.GetApplicationByClientId(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetOrganization(ctx context.Context, name string) (*model.Organization, error) {
|
||||
return store.GetOrganizationByName(ctx, s.db, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetProvider(ctx context.Context, owner, name string) (*model.Provider, error) {
|
||||
return store.GetProvider(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetCert(ctx context.Context, owner, name string) (*model.Cert, error) {
|
||||
return store.GetCert(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
// SetPassword loads the canonical row and re-saves it with the plaintext, which
|
||||
// users.Update hashes exactly once (empty leaves the digest untouched). Passing
|
||||
// the full existing row means no other field is zeroed by the update.
|
||||
func (s *ormStore) SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u, Password: plaintext}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyPassword authenticates a human credential for the LDAP-bind feature seam. It
|
||||
// goes through users.Authenticate — the ONE lockout-enforcing choke point the login
|
||||
// form, the ROPC grant, and the registry token endpoint share — so an LDAP bind is
|
||||
// rate-limited (argon2id v1 / bcrypt v2, keyed by the org's password type) exactly
|
||||
// like every other human-credential path; no hash ever leaves the core. A locked
|
||||
// account returns false (the bind fails), folding lockout into the same negative
|
||||
// result as a wrong password — LDAP has no distinct "locked" signal.
|
||||
func (s *ormStore) VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
pwType := ""
|
||||
if org, oerr := store.GetOrganizationByName(ctx, s.db, owner); oerr == nil && org != nil {
|
||||
pwType = org.PasswordType
|
||||
}
|
||||
ok, _ := users.Authenticate(ctx, s.db, u, plaintext, pwType, time.Now())
|
||||
return ok, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package featurestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/hanzoai/iam/feature"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
func openFeatureStore(t *testing.T) feature.Store {
|
||||
t.Helper()
|
||||
db, err := store.Open("sqlite", filepath.Join(t.TempDir(), "iam.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return New(db)
|
||||
}
|
||||
|
||||
// The seam's user id is the stable opaque subject: AddUser mints it server-side,
|
||||
// and the id a module reads back MUST be the one GetUserByID resolves. A module
|
||||
// hands that id to a client as the user's handle and gets it back on the next
|
||||
// request, so a mismatch here means every lookup by id misses.
|
||||
func TestAddUserThenGetUserByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openFeatureStore(t)
|
||||
|
||||
in := &model.User{Owner: "acme", Name: "alice", Email: "alice@acme.example"}
|
||||
ok, err := s.AddUser(ctx, in)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("AddUser = %v, %v", ok, err)
|
||||
}
|
||||
// AddUser takes the user by value: the caller's struct is never stamped, so the
|
||||
// id is learned by re-reading the row.
|
||||
if in.Id != "" {
|
||||
t.Fatalf("AddUser stamped the caller's struct with %q; callers must re-read", in.Id)
|
||||
}
|
||||
|
||||
row, err := s.GetUser(ctx, "acme", "alice")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("GetUser = %v, %v", row, err)
|
||||
}
|
||||
if _, err := uuid.Parse(row.Id); err != nil {
|
||||
t.Fatalf("assigned id %q is not the opaque UUID subject: %v", row.Id, err)
|
||||
}
|
||||
if strings.Contains(row.Id, "/") {
|
||||
t.Fatalf("id %q carries a slash; unusable as a /Users/{id} path segment", row.Id)
|
||||
}
|
||||
|
||||
got, err := s.GetUserByID(ctx, row.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID(%q): %v", row.Id, err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("GetUserByID(%q) found nothing — the id AddUser assigned does not resolve", row.Id)
|
||||
}
|
||||
if got.Owner != "acme" || got.Name != "alice" {
|
||||
t.Fatalf("GetUserByID resolved %s/%s, want acme/alice", got.Owner, got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// The id survives an update unchanged, so a module's stored resource id stays
|
||||
// valid: UpdateUser carries Id (and CreatedTime) forward and ignores a body value.
|
||||
func TestUpdateUserPreservesTheSubject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openFeatureStore(t)
|
||||
|
||||
if _, err := s.AddUser(ctx, &model.User{Owner: "acme", Name: "bob"}); err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
before, _ := s.GetUser(ctx, "acme", "bob")
|
||||
if before == nil {
|
||||
t.Fatal("GetUser after AddUser: nil")
|
||||
}
|
||||
|
||||
// A body that tries to move the subject must be ignored.
|
||||
edit := *before
|
||||
edit.Id = uuid.NewString()
|
||||
edit.DisplayName = "Bob"
|
||||
if _, err := s.UpdateUser(ctx, &edit); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
|
||||
after, _ := s.GetUser(ctx, "acme", "bob")
|
||||
if after == nil {
|
||||
t.Fatal("GetUser after UpdateUser: nil")
|
||||
}
|
||||
if after.Id != before.Id {
|
||||
t.Fatalf("update moved the subject: %q -> %q", before.Id, after.Id)
|
||||
}
|
||||
if after.DisplayName != "Bob" {
|
||||
t.Fatalf("DisplayName = %q, want Bob", after.DisplayName)
|
||||
}
|
||||
if got, err := s.GetUserByID(ctx, before.Id); err != nil || got == nil {
|
||||
t.Fatalf("GetUserByID after update = %v, %v; the original id must still resolve", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unmatched id is (nil, nil), not an error — callers turn it into a 404.
|
||||
func TestGetUserByIDUnknown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openFeatureStore(t)
|
||||
got, err := s.GetUserByID(ctx, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID(unknown) errored: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("GetUserByID(unknown) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// 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
|
||||
// portal consume, plus small helpers over zip.Ctx. Every front-door JSON
|
||||
// endpoint (get-app-login, login, signup) returns this shape; the OIDC
|
||||
// endpoints (token/authorize/userinfo) use their own RFC 6749 shapes.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
// Code is a STABLE machine-readable reason, where the human `msg` is
|
||||
// deliberately generic. `msg` is prose for a person and several distinct causes
|
||||
// legitimately share one sentence; a caller that must BRANCH on the cause — or
|
||||
// tell its own user which of them happened — cannot parse prose. Optional, so
|
||||
// every existing envelope is byte-identical and no SDK changes.
|
||||
Code string `json:"code,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
Data any `json:"data"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
Data3 any `json:"data3,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceToken returns the configured unified service token — the first non-empty
|
||||
// of HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — or "" (fail closed).
|
||||
// This is the ONE system credential the service-token surfaces (operator bootstrap
|
||||
// and admin provisioning) authenticate against.
|
||||
func ServiceToken() string {
|
||||
for _, key := range []string{"HANZO_API_KEY", "KMS_SERVICE_TOKEN", "IAM_SERVICE_TOKEN"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 := 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 {
|
||||
return write(c, Good(data, more...))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ErrCode(c *zip.Ctx, msg, code string) error {
|
||||
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 { 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 "
|
||||
if len(h) > len(p) && h[:len(p)] == p {
|
||||
return h[len(p):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Basic returns the (id, secret) an `Authorization: Basic <base64>` header carries,
|
||||
// and whether it carried one — RFC 7617: base64 of "<id>:<secret>", split on the
|
||||
// FIRST colon so a secret may contain one. This is the ONE Basic parser; a caller
|
||||
// bound by RFC 6749 §2.3.1 (client_secret_basic, whose halves are form-urlencoded
|
||||
// before the base64) form-decodes the two values afterwards.
|
||||
func Basic(c *zip.Ctx) (id, secret string, ok bool) {
|
||||
const p = "Basic "
|
||||
h := c.Header("Authorization")
|
||||
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(h[len(p):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
id, secret, found := strings.Cut(string(raw), ":")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
return id, secret, true
|
||||
}
|
||||
|
||||
// The request host is read through the ONE header-immune accessor, zip.Ctx.Host()
|
||||
// — the same seam the OIDC issuer resolver uses. It ignores X-Forwarded-Host (zip
|
||||
// has no trusted-proxy knob), so the brand host a client authenticates to cannot
|
||||
// be spoofed by a request header. There is deliberately no EffectiveHost helper
|
||||
// here: a second accessor that honored X-Forwarded-Host would reopen that spoof.
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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
|
||||
// operation is a typed zip handler over hanzoai/orm; the orm string key is
|
||||
// "owner/name". Reads scope to one owner (organization); writes address one
|
||||
// invitation by its (owner, name) key.
|
||||
package invitations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// Handler binds the invitations operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the invitations CRUD routes on app against db.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/invitations", h.List, zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations", h.Create, zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/get", h.Get, zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/update", h.Update, zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/delete", h.Delete, zip.WithTags("invitations"))
|
||||
}
|
||||
|
||||
// Ref addresses one invitation by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Input is the writable projection of an invitation (the v1 add/update-invitation
|
||||
// body). It keeps the HTTP contract clean of the orm.Model bookkeeping fields.
|
||||
type Input struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Code string `json:"code"`
|
||||
IsRegexp bool `json:"isRegexp"`
|
||||
Quota int `json:"quota"`
|
||||
UsedCount int `json:"usedCount"`
|
||||
Application string `json:"application"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
SignupGroup string `json:"signupGroup"`
|
||||
DefaultCode string `json:"defaultCode"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of invitations.
|
||||
type ListOutput struct {
|
||||
Invitations []*schema.Invitation `json:"invitations"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// apply copies the mutable domain fields of an Input onto an invitation. The
|
||||
// identity fields (owner, name) and the created stamp are set only on Create,
|
||||
// never overwritten by an update.
|
||||
func apply(dst *schema.Invitation, in *Input) {
|
||||
dst.UpdatedTime = in.UpdatedTime
|
||||
dst.DisplayName = in.DisplayName
|
||||
dst.Code = in.Code
|
||||
dst.IsRegexp = in.IsRegexp
|
||||
dst.Quota = in.Quota
|
||||
dst.UsedCount = in.UsedCount
|
||||
dst.Application = in.Application
|
||||
dst.Username = in.Username
|
||||
dst.Email = in.Email
|
||||
dst.Phone = in.Phone
|
||||
dst.SignupGroup = in.SignupGroup
|
||||
dst.DefaultCode = in.DefaultCode
|
||||
dst.State = in.State
|
||||
}
|
||||
|
||||
// List returns your organization's invitations, newest first — who has
|
||||
// been asked to join, on what terms, and how many seats each invitation still
|
||||
// has left.
|
||||
//
|
||||
// You see your own organization's invitations and no one else's; which organization that
|
||||
// is comes from your credentials, not from the request.
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
// The owner is resolved by authz.Scope from the authenticated principal,
|
||||
// never taken from the input: a tenant reads only its own org, a SuperAdmin
|
||||
// reads the owner it asks for. Filtering on in.Owner instead was a confused
|
||||
// deputy — the Guard authorizes on the query string, then a typed GET binds
|
||||
// NOTHING from it (zip typed.go reads a body only for non-GET), so in.Owner
|
||||
// arrived empty on every REST call and the "empty owner lists everything"
|
||||
// branch returned every tenant.
|
||||
owner, err := authz.Scope(ctx, in.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.Invitation](h.db)
|
||||
if owner != "" {
|
||||
q = q.Filter("owner", owner)
|
||||
}
|
||||
invitations, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &ListOutput{Invitations: invitations, Total: len(invitations)}, nil
|
||||
}
|
||||
|
||||
// Get returns one invitation: who it is for, what it grants on acceptance, and
|
||||
// when it expires.
|
||||
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Create issues an invitation to join your organization — the code or link a new
|
||||
// member redeems, with the role they arrive holding and the date it stops
|
||||
// working. A name already used in the organization is refused.
|
||||
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("invitation already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
invitation := orm.New[schema.Invitation](h.db)
|
||||
invitation.Owner = in.Owner
|
||||
invitation.Name = in.Name
|
||||
invitation.CreatedTime = in.CreatedTime
|
||||
if invitation.CreatedTime == "" {
|
||||
invitation.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
apply(invitation, in)
|
||||
invitation.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := invitation.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Update changes an invitation's terms — the role it grants, how many may redeem
|
||||
// it, or when it expires. What it is called does not change.
|
||||
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
apply(invitation, in)
|
||||
if err := invitation.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Delete withdraws an invitation. It stops being redeemable at once; anyone who
|
||||
// already joined through it keeps their account.
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := invitation.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("invitation not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package invitations
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/invitations", zip.Doc{
|
||||
Description: "Returns your organization's invitations, newest first — who has\nbeen asked to join, on what terms, and how many seats each invitation still\nhas left.\n\nYou see your own organization's invitations and no one else's; which organization that\nis comes from your credentials, not from the request.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/invitations", zip.Doc{
|
||||
Description: "Issues an invitation to join your organization — the code or link a new\nmember redeems, with the role they arrive holding and the date it stops\nworking. A name already used in the organization is refused.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/invitations/delete", zip.Doc{
|
||||
Description: "Withdraws an invitation. It stops being redeemable at once; anyone who\nalready joined through it keeps their account.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/invitations/get", zip.Doc{
|
||||
Description: "Returns one invitation: who it is for, what it grants on acceptance, and\nwhen it expires.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/invitations/update", zip.Doc{
|
||||
Description: "Changes an invitation's terms — the role it grants, how many may redeem\nit, or when it expires. What it is called does not change.",
|
||||
Fields: map[string]string{
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Invitation].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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.
|
||||
//
|
||||
// Identity is the (owner, name) pair; it maps onto the orm storage id as
|
||||
// "owner/name", exactly as the v1 record addressed itself. Reads are
|
||||
// zip.Get[In,Out], writes are zip.Post[In,Out]; every handler closes over the
|
||||
// one orm.DB entity store so the typed signatures carry no transport or
|
||||
// storage plumbing.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Route registers the key CRUD routes on app, binding each handler to db.
|
||||
// Called from routes.Route once it is threaded the entity store.
|
||||
//
|
||||
// ONE noun, plural, for every op — the same shape users.Route uses
|
||||
// (/v1/iam/users, /v1/iam/users/get, …). It used to be two nouns, `keys` for the
|
||||
// list and `key` for everything else, and that was not merely inconsistent:
|
||||
// authz.entityOf reads the FIRST path segment as the entity, so the list
|
||||
// authorized on "keys" and every write on "key". Two entity strings for one
|
||||
// entity means every capability keyed on it is dead on one of the two surfaces —
|
||||
// the same defect entityNoun was written to fix for the legacy verb spellings.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
zip.Get(app, "/v1/iam/keys", list(db), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/keys", create(db), zip.WithTags("keys"))
|
||||
zip.Get(app, "/v1/iam/keys/get", get(db), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/keys/update", update(db), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/keys/delete", del(db), zip.WithTags("keys"))
|
||||
}
|
||||
|
||||
// ListRequest scopes a listing to one owner.
|
||||
type ListRequest struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListResponse is the owner-scoped key set, newest first.
|
||||
type ListResponse struct {
|
||||
Keys []schema.Key `json:"keys"`
|
||||
}
|
||||
|
||||
// Ref addresses one key by its (owner, name) identity.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// DeleteResponse reports whether the key was removed.
|
||||
type DeleteResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// id joins the owner-scoped natural key into the orm storage id — the same
|
||||
// "owner/name" identity the v1 record used.
|
||||
func id(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// list returns your organization's API keys, newest first — what each is called,
|
||||
// what it may reach, and its publishable half. Secret halves are never listed.
|
||||
func list(db orm.DB) zip.TypedHandler[ListRequest, ListResponse] {
|
||||
return func(ctx context.Context, in *ListRequest) (*ListResponse, error) {
|
||||
if in.Owner == "" {
|
||||
return nil, zip.ErrBadRequest("owner is required")
|
||||
}
|
||||
items, err := orm.TypedQuery[schema.Key](db).
|
||||
Filter("Owner=", in.Owner).
|
||||
Order("-CreatedTime").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
out := &ListResponse{Keys: make([]schema.Key, 0, len(items))}
|
||||
for _, k := range items {
|
||||
out.Keys = append(out.Keys, *k.Mask())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// get returns one API key: what it is called, what it may reach, and when it was
|
||||
// issued.
|
||||
func get(db orm.DB) zip.TypedHandler[Ref, schema.Key] {
|
||||
return func(_ context.Context, in *Ref) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return k.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// create issues an API key. A standard key comes back as a publishable half you
|
||||
// may ship in client code and a secret half you must not — the secret is shown
|
||||
// once, at creation, and cannot be retrieved afterwards. A publish-scoped key is
|
||||
// issued with the publishable half only, so there is no secret to leak.
|
||||
//
|
||||
// A name already used in your organization is refused rather than reissued, so
|
||||
// creating twice never silently invalidates a key that is in production.
|
||||
func create(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
|
||||
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := sameTenantUser(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := orm.Get[schema.Key](db, id(in.Owner, in.Name)); err == nil {
|
||||
return nil, zip.ErrConflict("key already exists: " + id(in.Owner, in.Name))
|
||||
} else if !errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
k := orm.New[schema.Key](db)
|
||||
k.SetId(id(in.Owner, in.Name))
|
||||
k.Owner, k.Name = in.Owner, in.Name
|
||||
apply(k, in)
|
||||
// Scope is settable HERE and only here: it is the key's access class, chosen
|
||||
// when the key is minted and fixed thereafter (apply deliberately does not
|
||||
// carry it, so an update cannot flip a secret key to publish scope and blank
|
||||
// its secret).
|
||||
k.Scope = in.Scope
|
||||
if k.AccessKey == "" {
|
||||
k.AccessKey = Mint("pk", k.State)
|
||||
}
|
||||
if k.Scope == schema.KeyScopePublish {
|
||||
// A publishable key is WRITE-ONLY: a pk- publishable half and NEVER a
|
||||
// confidential sk- secret — even if the caller supplied one — so it can
|
||||
// carry no full-access material. Its authority is resolved org-only at the
|
||||
// ingest door (compat resolve-key → /v1/iam/resolve-key), never as a principal.
|
||||
k.AccessSecret = ""
|
||||
} else if k.AccessSecret == "" {
|
||||
k.AccessSecret = Mint("sk", k.State)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
k.CreatedTime, k.UpdatedTime = now, now
|
||||
|
||||
if err := k.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
|
||||
// update changes what a key is called or what it may reach. The credential
|
||||
// itself is not reissued — the key in your deployment keeps working.
|
||||
func update(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
|
||||
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := sameTenantUser(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apply(k, in)
|
||||
if k.Scope == schema.KeyScopePublish {
|
||||
// Keep a publishable key write-only for its whole lifecycle: an update can
|
||||
// never attach a confidential sk- secret to a pk--only browser key.
|
||||
k.AccessSecret = ""
|
||||
}
|
||||
k.UpdatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := k.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
// An edit is not a mint: the secret is revealed ONCE, by create. Echoing it
|
||||
// from every update would turn "rename this key" into "re-read its secret".
|
||||
return k.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// del revokes an API key. Anything still presenting it stops being authorized at
|
||||
// once, so roll the replacement out before you revoke.
|
||||
func del(db orm.DB) zip.TypedHandler[Ref, DeleteResponse] {
|
||||
return func(ctx context.Context, in *Ref) (*DeleteResponse, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := k.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteResponse{Deleted: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// sameTenantUser rejects a Key whose User field names a DIFFERENT owner than the key
|
||||
// itself — the write-side half of the F1 credential-forgery gate (store.userOwningKey
|
||||
// is the authoritative half). Key.User and the credential halves are all
|
||||
// caller-supplied, and the key write is authorized only on (Owner, Name), so a
|
||||
// "/"-qualified User naming "admin/z" or a victim tenant would otherwise persist and
|
||||
// let a presented sk- secret resolve — via get-user?accessKey — to that foreign /
|
||||
// SuperAdmin identity. (The public pk- half never resolves to a principal at all, so
|
||||
// this gate protects the sk- read path.) A bare username or an empty User is fine
|
||||
// (both resolve within the key's own owner); a cross-tenant qualified reference is
|
||||
// refused, so no forged row is ever written.
|
||||
func sameTenantUser(k *schema.Key) error {
|
||||
if o, _, ok := strings.Cut(k.User, "/"); ok && o != k.Owner {
|
||||
return zip.ErrBadRequest("key user must belong to the key's owner")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply copies the caller-settable fields from src onto dst, leaving the
|
||||
// (owner, name) identity, storage id, audit stamps AND THE CREDENTIAL ITSELF under
|
||||
// handler control.
|
||||
//
|
||||
// AccessKey/AccessSecret are deliberately NOT copied. They authenticate: a secret
|
||||
// key's sk- half resolves its owning user by exact match (store.userOwningKey), so
|
||||
// copying a caller-supplied value lets the sender choose a credential it already
|
||||
// knows and then present it as that key's principal. Minting is the only writer.
|
||||
// This matters more the moment the secret is stored as a digest rather than
|
||||
// verbatim — a chosen digest is a forgery, not merely a chosen password.
|
||||
//
|
||||
// Scope is not copied either: it is the key's ACCESS CLASS, fixed at create. Letting
|
||||
// an update flip a secret key to publish scope would blank its AccessSecret and make
|
||||
// its pk- half org-resolvable at the ingest door — a privilege change disguised as an
|
||||
// edit. Rotation and re-scoping are mint operations, not field writes.
|
||||
func apply(dst, src *schema.Key) {
|
||||
dst.DisplayName = src.DisplayName
|
||||
dst.Type = src.Type
|
||||
dst.Organization = src.Organization
|
||||
dst.Application = src.Application
|
||||
dst.User = src.User
|
||||
dst.ExpireTime = src.ExpireTime
|
||||
dst.State = src.State
|
||||
}
|
||||
|
||||
// mint generates a prefixed credential half — "{pk|sk}-{live|test}-{random}"
|
||||
// — mirroring the v1 key format. State == "test" selects the test env.
|
||||
func Mint(prefix, state string) string {
|
||||
env := "live"
|
||||
if state == "test" {
|
||||
env = "test"
|
||||
}
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("%s-%s-%s", prefix, env, hex.EncodeToString(b[:]))
|
||||
}
|
||||
|
||||
// UserKeyName and PublishKeyName are the deterministic Names of the ONE key a user
|
||||
// holds AT EACH SCOPE. Deterministic so a re-mint REPLACES the previous credential
|
||||
// instead of leaving a second live one behind — a user has one key per scope, and
|
||||
// revoking it revokes them at that scope.
|
||||
//
|
||||
// Two rows, not one, because the two scopes are different credentials with opposite
|
||||
// exposure: the secret key authenticates its holder as the user, the publishable key
|
||||
// resolves to an org and is shipped in client JS. Holding both is the normal case (a
|
||||
// server SDK and a browser beacon), and rotating the browser key must not sign the
|
||||
// user out of their own API.
|
||||
const (
|
||||
UserKeyName = "cloud-api"
|
||||
PublishKeyName = "publishable"
|
||||
)
|
||||
|
||||
// NameFor is the deterministic key Name for a scope: the ONE mapping from a key's
|
||||
// access class to the row that holds it, so mint, revoke and read can never
|
||||
// disagree about which row a scope means.
|
||||
func NameFor(scope string) string {
|
||||
if scope == schema.KeyScopePublish {
|
||||
return PublishKeyName
|
||||
}
|
||||
return UserKeyName
|
||||
}
|
||||
|
||||
// MintUserKey (re)mints the single credential a user holds at `scope` and returns the
|
||||
// half its holder presents — revealed once:
|
||||
//
|
||||
// - "" (the default, secret): the confidential sk- half. Resolves to the USER
|
||||
// (store.userOwningKey queries schema.Key.AccessSecret), so it is session-
|
||||
// equivalent and must never be shipped to a browser.
|
||||
// - schema.KeyScopePublish: the publishable pk- half, and NO secret is stored at
|
||||
// all. Resolves to just the ORG (store.PublishableKeyByAccessKey), never a
|
||||
// principal, which is exactly what makes it safe in client JS. This is the ONLY
|
||||
// path that mints one, and its absence is why every surface configured its own
|
||||
// ingest credential.
|
||||
//
|
||||
// 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. 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) {
|
||||
if strings.TrimSpace(owner) == "" || strings.TrimSpace(user) == "" {
|
||||
return "", fmt.Errorf("keys: owner and user are required")
|
||||
}
|
||||
publish := scope == schema.KeyScopePublish
|
||||
// The credential the holder presents, and the ONE value returned. A publishable
|
||||
// key has no secret half — not an empty one, none — so there is nothing else it
|
||||
// could return and nothing a leak of the row could reveal.
|
||||
access, secret := Mint("pk", ""), Mint("sk", "")
|
||||
presented := secret
|
||||
if publish {
|
||||
secret = ""
|
||||
presented = access
|
||||
}
|
||||
name := NameFor(scope)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
existing, err := orm.TypedQuery[schema.Key](db).Filter("Id=", id(owner, name)).First()
|
||||
if err != nil && !errors.Is(err, orm.ErrNotFound) {
|
||||
return "", err
|
||||
}
|
||||
if existing != nil {
|
||||
existing.AccessKey, existing.AccessSecret = access, secret
|
||||
existing.User, existing.Type, existing.Scope = user, "User", scope
|
||||
existing.UpdatedTime = now
|
||||
if err := existing.UpdateCtx(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return presented, nil
|
||||
}
|
||||
|
||||
k := orm.New[schema.Key](db)
|
||||
k.SetId(id(owner, name))
|
||||
k.Owner, k.Name = owner, name
|
||||
k.DisplayName = "Cloud API key"
|
||||
if publish {
|
||||
k.DisplayName = "Publishable key"
|
||||
}
|
||||
k.Type, k.User = "User", user
|
||||
k.AccessKey = access
|
||||
k.AccessSecret = secret
|
||||
k.Scope = scope
|
||||
k.State = "Active"
|
||||
k.CreatedTime, k.UpdatedTime = now, now
|
||||
if err := k.CreateCtx(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return presented, nil
|
||||
}
|
||||
|
||||
// RevokeUserKey deletes the user's key row at `scope`. Absent is success — revoke is
|
||||
// a statement about the END state, so a caller can always assert "this user holds no
|
||||
// credential" without racing a prior revoke. Scoped, so revoking the browser key
|
||||
// leaves the server key working and vice versa.
|
||||
func RevokeUserKey(ctx context.Context, db orm.DB, owner, scope string) error {
|
||||
k, err := orm.TypedQuery[schema.Key](db).Filter("Id=", id(owner, NameFor(scope))).First()
|
||||
if errors.Is(err, orm.ErrNotFound) || k == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return k.DeleteCtx(ctx)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package keys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
func memDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "keys.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// F1 write-side gate: keys.create and keys.update must REJECT a Key whose User field
|
||||
// names a different owner than the key — the row that would let get-user?accessKey
|
||||
// forge a cross-tenant / SuperAdmin identity can never be persisted. A same-owner or
|
||||
// bare User is accepted.
|
||||
func TestKeys_RejectCrossTenantUserOnWrite(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
c := create(db)
|
||||
|
||||
// Cross-tenant qualified User → rejected (attacker in "a" pointing at admin/z).
|
||||
if _, err := c(ctx, &schema.Key{Owner: "a", Name: "forge", User: "admin/z"}); err == nil {
|
||||
t.Fatal("create accepted a cross-tenant User reference (forgery row)")
|
||||
}
|
||||
|
||||
// Same-owner qualified User → accepted.
|
||||
ok, err := c(ctx, &schema.Key{Owner: "a", Name: "own", User: "a/alice"})
|
||||
if err != nil || ok == nil {
|
||||
t.Fatalf("create rejected a same-owner User: %v", err)
|
||||
}
|
||||
// Bare username → accepted (resolves within the key's own owner).
|
||||
if _, err := c(ctx, &schema.Key{Owner: "a", Name: "bare", User: "bob"}); err != nil {
|
||||
t.Fatalf("create rejected a bare username: %v", err)
|
||||
}
|
||||
|
||||
// update must enforce it too: flipping an existing key's User cross-tenant fails.
|
||||
u := update(db)
|
||||
if _, err := u(ctx, &schema.Key{Owner: "a", Name: "own", User: "victimorg/ceo"}); err == nil {
|
||||
t.Fatal("update accepted a cross-tenant User reference (forgery row)")
|
||||
}
|
||||
// A same-owner update still works.
|
||||
if _, err := u(ctx, &schema.Key{Owner: "a", Name: "own", User: "a/carol"}); err != nil {
|
||||
t.Fatalf("update rejected a same-owner User: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A publishable key (Scope=publish) mints a pk- publishable half ONLY — never a
|
||||
// confidential sk- secret — so it can carry no full-access material. A default key
|
||||
// still mints BOTH halves (its sk- is the reader-authenticating credential).
|
||||
func TestKeys_PublishableMintsNoSecret(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
c := create(db)
|
||||
|
||||
pub, err := c(ctx, &schema.Key{Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish})
|
||||
if err != nil {
|
||||
t.Fatalf("create publish key: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(pub.AccessKey, "pk-") {
|
||||
t.Fatalf("publish key AccessKey = %q, want a pk-", pub.AccessKey)
|
||||
}
|
||||
if pub.AccessSecret != "" {
|
||||
t.Fatalf("publish key minted a secret %q, want none (write-only)", pub.AccessSecret)
|
||||
}
|
||||
|
||||
def, err := c(ctx, &schema.Key{Owner: "hanzo", Name: "server"})
|
||||
if err != nil {
|
||||
t.Fatalf("create default key: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(def.AccessKey, "pk-") || !strings.HasPrefix(def.AccessSecret, "sk-") {
|
||||
t.Fatalf("default key halves = %q/%q, want pk-/sk-", def.AccessKey, def.AccessSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// A publishable key is write-only even if the caller SUPPLIES a secret: create and
|
||||
// update both force AccessSecret empty, so a browser key can never carry a confidential
|
||||
// half for its whole lifecycle.
|
||||
func TestKeys_PublishableForcesSecretEmpty(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Caller tries to smuggle a secret onto a publish key at create.
|
||||
pub, err := create(db)(ctx, &schema.Key{
|
||||
Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish,
|
||||
AccessKey: "pk-live-CHOSEN", AccessSecret: "sk-live-SMUGGLED",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if pub.AccessSecret != "" {
|
||||
t.Fatalf("create let a publish key keep a supplied secret %q", pub.AccessSecret)
|
||||
}
|
||||
|
||||
// And again at update — the invariant holds across the key's lifecycle.
|
||||
upd, err := update(db)(ctx, &schema.Key{
|
||||
Owner: "hanzo", Name: "site", Scope: schema.KeyScopePublish,
|
||||
AccessSecret: "sk-live-SMUGGLED2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if upd.AccessSecret != "" {
|
||||
t.Fatalf("update let a publish key gain a secret %q", upd.AccessSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// The round-trip that did not exist, and whose absence let a dead credential ship:
|
||||
// a key minted by mint-user-keys MUST resolve back to the user it was minted for.
|
||||
//
|
||||
// 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.
|
||||
func TestMintUserKey_ResolvesBackToItsUser(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
|
||||
if err != nil {
|
||||
t.Fatalf("MintUserKey: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(secret, "sk-") {
|
||||
t.Fatalf("minted secret = %q, want an sk- confidential half", secret[:3])
|
||||
}
|
||||
|
||||
// The row the resolver reads must exist, name its user, and hold the secret.
|
||||
k, err := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First()
|
||||
if err != nil || k == nil {
|
||||
t.Fatalf("no schema.Key row resolves the minted secret (err=%v) — this is the bug", err)
|
||||
}
|
||||
if k.User != "ada" || k.Owner != "acme" {
|
||||
t.Fatalf("key resolves to %s/%s, want acme/ada", k.Owner, k.User)
|
||||
}
|
||||
if !strings.HasPrefix(k.AccessKey, "pk-") {
|
||||
t.Fatalf("publishable half = %q, want pk-", k.AccessKey)
|
||||
}
|
||||
if k.Scope == schema.KeyScopePublish {
|
||||
t.Fatal("a user's authenticating key must NOT be publish-scoped")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-minting REPLACES the credential rather than leaving a second live secret: a
|
||||
// user holds one key, so revoking it revokes them.
|
||||
func TestMintUserKey_RemintReplacesRatherThanAccumulates(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := MintUserKey(ctx, db, "acme", "ada", "")
|
||||
if err != nil {
|
||||
t.Fatalf("first mint: %v", err)
|
||||
}
|
||||
second, err := MintUserKey(ctx, db, "acme", "ada", "")
|
||||
if err != nil {
|
||||
t.Fatalf("re-mint: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("re-mint returned the same secret; it must rotate")
|
||||
}
|
||||
if old, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", first).First(); old != nil {
|
||||
t.Fatal("the superseded secret still resolves — a revoked key would stay live")
|
||||
}
|
||||
if cur, err := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", second).First(); err != nil || cur == nil {
|
||||
t.Fatalf("the current secret does not resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke is a statement about the END state: after it, the user holds nothing, and
|
||||
// revoking again is still success (a caller may always assert "holds no credential").
|
||||
func TestRevokeUserKey_EndStateAndIdempotent(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if err := RevokeUserKey(ctx, db, "acme", ""); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k != nil {
|
||||
t.Fatal("secret still resolves after revoke")
|
||||
}
|
||||
if err := RevokeUserKey(ctx, db, "acme", ""); err != nil {
|
||||
t.Fatalf("revoke on an already-revoked user must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller must not be able to CHOOSE a key's credential. The sk- half resolves its
|
||||
// owning user by exact match (store.userOwningKey), so a body that carries one lets
|
||||
// the sender pick a secret it already knows and then present it as that principal.
|
||||
// This is a forgery primitive the moment the secret is stored as a digest.
|
||||
func TestKeys_CallerCannotChooseTheCredential(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
k, err := create(db)(ctx, &schema.Key{
|
||||
Owner: "acme", Name: "planted",
|
||||
AccessKey: "pk-live-chosen-by-caller",
|
||||
AccessSecret: "sk-live-chosen-by-caller",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if k.AccessSecret == "sk-live-chosen-by-caller" {
|
||||
t.Fatal("caller-supplied AccessSecret was persisted — a chosen credential is a forgery")
|
||||
}
|
||||
if k.AccessKey == "pk-live-chosen-by-caller" {
|
||||
t.Fatal("caller-supplied AccessKey was persisted")
|
||||
}
|
||||
if !strings.HasPrefix(k.AccessSecret, "sk-") || !strings.HasPrefix(k.AccessKey, "pk-") {
|
||||
t.Fatalf("both halves must be minted; got key=%q secret-prefix=%q", k.AccessKey, k.AccessSecret[:3])
|
||||
}
|
||||
}
|
||||
|
||||
// Scope is the ACCESS CLASS and is fixed at mint. An update that could flip a secret
|
||||
// key to publish scope would blank its secret and make its pk- half org-resolvable at
|
||||
// the ingest door — a privilege change wearing the clothes of a profile edit.
|
||||
func TestKeys_UpdateCannotReScopeOrRotate(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
made, err := create(db)(ctx, &schema.Key{Owner: "acme", Name: "svc"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
secret, access := made.AccessSecret, made.AccessKey
|
||||
|
||||
got, err := update(db)(ctx, &schema.Key{
|
||||
Owner: "acme", Name: "svc",
|
||||
DisplayName: "renamed",
|
||||
Scope: schema.KeyScopePublish,
|
||||
AccessSecret: "sk-live-attacker",
|
||||
AccessKey: "pk-live-attacker",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if got.Scope == schema.KeyScopePublish {
|
||||
t.Fatal("update re-scoped a secret key to publish — that blanks the secret and opens the ingest door")
|
||||
}
|
||||
// Assert on the STORED row, not the response: the response is masked (an edit is
|
||||
// not a mint), so reading the secret back out of it would only ever prove the mask.
|
||||
stored, err := orm.Get[schema.Key](db, "acme/svc")
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if stored.AccessSecret != secret || stored.AccessKey != access {
|
||||
t.Fatal("update rotated the credential to caller-supplied values")
|
||||
}
|
||||
if got.AccessSecret != "" {
|
||||
t.Fatalf("update echoed the confidential secret %q — the secret is revealed once, by create", got.AccessSecret)
|
||||
}
|
||||
if got.DisplayName != "renamed" {
|
||||
t.Fatalf("update failed to apply a legitimately mutable field: %q", got.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// The gap that made a publishable key unmintable: there was NO path anywhere that
|
||||
// produced one for a user. IAM owned the model (schema.KeyScopePublish), the resolver
|
||||
// (store.PublishableKeyByAccessKey) and the ingest door (compat resolve-key), and
|
||||
// nothing minted the credential they were written for — so every surface configured
|
||||
// its own thing. Type is a field on the ONE mint, and this is the proof it works.
|
||||
func TestMintUserKey_PublishableTypeMintsAPublicKeyAndNoSecret(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
|
||||
if err != nil {
|
||||
t.Fatalf("MintUserKey(publish): %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "pk-") {
|
||||
t.Fatalf("publishable mint returned %q, want the pk- half — a browser key is the value you ship", got)
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, "acme/"+PublishKeyName)
|
||||
if err != nil {
|
||||
t.Fatalf("no publishable key row: %v", err)
|
||||
}
|
||||
if k.Scope != schema.KeyScopePublish {
|
||||
t.Fatalf("row scope = %q, want %q — the resolver refuses anything else", k.Scope, schema.KeyScopePublish)
|
||||
}
|
||||
if k.AccessSecret != "" {
|
||||
t.Fatalf("a publishable key stored a confidential secret %q — it must have no secret half at all", k.AccessSecret)
|
||||
}
|
||||
if k.AccessKey != got {
|
||||
t.Fatalf("returned %q but stored %q; the value handed out must be the value that resolves", got, k.AccessKey)
|
||||
}
|
||||
if k.User != "ada" || k.Owner != "acme" {
|
||||
t.Fatalf("publishable key filed under %s/%s, want acme/ada", k.Owner, k.User)
|
||||
}
|
||||
}
|
||||
|
||||
// The two scopes are two rows, so a user holds both at once and rotating one does not
|
||||
// touch the other. One row would make "rotate the key in my browser bundle" also sign
|
||||
// the holder out of their own API.
|
||||
func TestMintUserKey_ScopesAreIndependentCredentials(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
secret, err := MintUserKey(ctx, db, "acme", "ada", "")
|
||||
if err != nil {
|
||||
t.Fatalf("mint secret: %v", err)
|
||||
}
|
||||
pub, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
|
||||
if err != nil {
|
||||
t.Fatalf("mint publishable: %v", err)
|
||||
}
|
||||
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
|
||||
t.Fatal("minting the publishable key destroyed the secret key")
|
||||
}
|
||||
|
||||
// Rotating the publishable key leaves the secret key alone…
|
||||
pub2, err := MintUserKey(ctx, db, "acme", "ada", schema.KeyScopePublish)
|
||||
if err != nil {
|
||||
t.Fatalf("re-mint publishable: %v", err)
|
||||
}
|
||||
if pub2 == pub {
|
||||
t.Fatal("re-minting the publishable key did not rotate it")
|
||||
}
|
||||
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
|
||||
t.Fatal("rotating the publishable key revoked the secret key")
|
||||
}
|
||||
// …and revoking it likewise.
|
||||
if err := RevokeUserKey(ctx, db, "acme", schema.KeyScopePublish); err != nil {
|
||||
t.Fatalf("revoke publishable: %v", err)
|
||||
}
|
||||
if _, err := orm.Get[schema.Key](db, "acme/"+PublishKeyName); err == nil {
|
||||
t.Fatal("publishable key survived its own revoke")
|
||||
}
|
||||
if k, _ := orm.TypedQuery[schema.Key](db).Filter("AccessSecret=", secret).First(); k == nil {
|
||||
t.Fatal("revoking the publishable key revoked the secret key — the whole reason they are separate rows")
|
||||
}
|
||||
}
|
||||
|
||||
// A key LIST must never carry a confidential secret. Before schema.Key.Mask the list
|
||||
// handed every reader every sk- in the org verbatim, which meant read AUTHORIZATION
|
||||
// was standing in for redaction — and so widening who may see their own keys could
|
||||
// not be done safely. The publishable half survives the mask on purpose: it is the
|
||||
// value the holder needs, and it authenticates nobody.
|
||||
func TestKeys_ReadsMaskTheSecretAndKeepThePublishableHalf(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
made, err := create(db)(ctx, &schema.Key{Owner: "acme", Name: "svc", User: "ada"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if made.AccessSecret == "" {
|
||||
t.Fatal("create must reveal the secret ONCE, or a minted key is unusable")
|
||||
}
|
||||
|
||||
listed, err := list(db)(ctx, &ListRequest{Owner: "acme"})
|
||||
if err != nil || len(listed.Keys) != 1 {
|
||||
t.Fatalf("list: %v (%d keys)", err, len(listed.Keys))
|
||||
}
|
||||
if listed.Keys[0].AccessSecret != "" {
|
||||
t.Fatalf("list disclosed the confidential secret %q", listed.Keys[0].AccessSecret)
|
||||
}
|
||||
if listed.Keys[0].AccessKey != made.AccessKey {
|
||||
t.Fatalf("list blanked the publishable half (%q); the holder needs it", listed.Keys[0].AccessKey)
|
||||
}
|
||||
|
||||
one, err := get(db)(ctx, &Ref{Owner: "acme", Name: "svc"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if one.AccessSecret != "" {
|
||||
t.Fatalf("get disclosed the confidential secret %q", one.AccessSecret)
|
||||
}
|
||||
// And the STORED secret is untouched — the mask is a projection, not a deletion.
|
||||
stored, err := orm.Get[schema.Key](db, "acme/svc")
|
||||
if err != nil || stored.AccessSecret != made.AccessSecret {
|
||||
t.Fatal("masking a read mutated the stored credential")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package keys
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/iam/keys", zip.Doc{
|
||||
Description: "Returns your organization's API keys, newest first — what each is called,\nwhat it may reach, and its publishable half. Secret halves are never listed.",
|
||||
Fields: map[string]string{
|
||||
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
|
||||
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
|
||||
"Key.displayName": "DisplayName is the human-facing label.",
|
||||
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
|
||||
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
|
||||
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
|
||||
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/iam/keys/get", zip.Doc{
|
||||
Description: "Returns one API key: what it is called, what it may reach, and when it was\nissued.",
|
||||
Fields: map[string]string{
|
||||
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
|
||||
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
|
||||
"Key.displayName": "DisplayName is the human-facing label.",
|
||||
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
|
||||
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
|
||||
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
|
||||
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/keys", zip.Doc{
|
||||
Description: "Issues an API key. A standard key comes back as a publishable half you\nmay ship in client code and a secret half you must not — the secret is shown\nonce, at creation, and cannot be retrieved afterwards. A publish-scoped key is\nissued with the publishable half only, so there is no secret to leak.\n\nA name already used in your organization is refused rather than reissued, so\ncreating twice never silently invalidates a key that is in production.",
|
||||
Fields: map[string]string{
|
||||
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
|
||||
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
|
||||
"Key.displayName": "DisplayName is the human-facing label.",
|
||||
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
|
||||
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
|
||||
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
|
||||
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/iam/keys/delete", zip.Doc{
|
||||
Description: "Revokes an API key. Anything still presenting it stops being authorized at\nonce, so roll the replacement out before you revoke.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/keys/update", zip.Doc{
|
||||
Description: "Changes what a key is called or what it may reach. The credential\nitself is not reissued — the key in your deployment keeps working.",
|
||||
Fields: map[string]string{
|
||||
"Key.accessKey": "AccessKey (pk-*) is the publishable identifier and lookup index;\nAccessSecret (sk-*) is the confidential secret.",
|
||||
"Key.createdTime": "CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings\nfor byte-parity with the v1 row (orm.Model separately tracks CreatedAt /\nUpdatedAt as time.Time for the store's own lifecycle).",
|
||||
"Key.displayName": "DisplayName is the human-facing label.",
|
||||
"Key.expireTime": "ExpireTime is when the key stops being honored (empty = never). State is\nthe lifecycle flag (\"Active\", \"test\", …); \"test\" mints test-env\ncredentials instead of live ones.",
|
||||
"Key.owner": "Owner is the tenant that holds the key; Name is unique within Owner.",
|
||||
"Key.scope": "Scope is the key's ACCESS CLASS, orthogonal to Type (which names the bound\nprincipal). Empty (the default, \"secret\") is a full key: a pk- publishable\nhalf AND a confidential sk- half, the sk- authenticating a server-side reader.\nKeyScopePublish is a WRITE-ONLY publishable key — a pk- half only, no secret —\nthat resolves to just an ORG (never a principal) at the ingest door and is safe\nto ship in client JS. A missing value on an existing row reads as the default,\nso every pre-Scope key is a secret key unchanged.",
|
||||
"Key.type": "Type is the scope the key is bound to — \"Organization\", \"Application\",\n\"User\", or \"General\" — and Organization / Application / User name the\nconcrete principal for whichever scope Type selects.",
|
||||
"Model[github.com/hanzoai/iam/pkg/schema.Key].id": "Persisted fields",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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
|
||||
// carries as the `orgs` claim, which is what lets the edge authorize an
|
||||
// org-switch statelessly (X-Org-Id ∈ orgs).
|
||||
//
|
||||
// A user's HOME org (User.Owner) is always an implicit membership — the token
|
||||
// consumer treats it as one — so an explicit row is only ever needed for a TEAM
|
||||
// org the identity was invited into. The boot backfill seeds the home row anyway,
|
||||
// so an org's roster is complete from one query.
|
||||
//
|
||||
// This is the transport face. The relation's operations are store's
|
||||
// (EnsureMembership, MembershipsByUser/ByOrg), because the token mint needs them
|
||||
// too and it sits below the authorization seam this face sits above.
|
||||
package memberships
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Path is the REST verb face: GET lists by ?user= or ?org=, POST ensures one.
|
||||
//
|
||||
// PathGet/PathAdd/PathDelete are the legacy VERB spellings the cloud team-invite
|
||||
// path (clients/team/invite.go) hard-codes — get-memberships / add-membership /
|
||||
// delete-membership. They are aliases, not a second implementation: get/add reuse
|
||||
// the very handlers the REST face registers, and delete is the one handler REST
|
||||
// does not expose. So a backend swap serves the cloud verbs with the SAME store and
|
||||
// the SAME authz gates as the native REST surface.
|
||||
const (
|
||||
Path = "/v1/iam/memberships"
|
||||
PathGet = "/v1/iam/get-memberships"
|
||||
PathAdd = "/v1/iam/add-membership"
|
||||
PathDelete = "/v1/iam/delete-membership"
|
||||
)
|
||||
|
||||
// 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
|
||||
// not carry. get-memberships is a GET whose target rides in ?user=/?org=, so it is
|
||||
// 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) {
|
||||
zip.Get[lookup, httpx.Answer](app, Path, list(db),
|
||||
zip.WithStatus(200, 400),
|
||||
zip.WithTags("memberships"))
|
||||
app.Post(Path, ensure(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>"
|
||||
Org string `json:"org"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// list answers either question about who belongs where: which organizations one
|
||||
// person can act in, or who can act in one organization.
|
||||
//
|
||||
// Both are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or
|
||||
// about a user whose home org is its own, and nothing else. The bound comes from
|
||||
// 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.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 in.Org != "" {
|
||||
if !scoped(ctx, in.Org) {
|
||||
return httpx.Bad(400, unauthorized, ""), nil
|
||||
}
|
||||
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(in.User, "/")
|
||||
if !found || home == "" {
|
||||
return httpx.Bad(400, "user must be <owner>/<name>", ""), nil
|
||||
}
|
||||
if !scoped(ctx, home) {
|
||||
return httpx.Bad(400, unauthorized, ""), nil
|
||||
}
|
||||
return listed(store.MembershipsByUser(ctx, db, in.User))
|
||||
}
|
||||
}
|
||||
|
||||
// ensure lets a person or an application act in an organization. It is the grant
|
||||
// behind "add someone to the team", and it is safe to repeat — granting a
|
||||
// membership that already exists changes nothing. Granting membership IS the org's authority to give, so it takes the
|
||||
// same gate a write to that org's own registry row takes: a SuperAdmin, an admin
|
||||
// of the org itself, or an org-admin-capable confidential client. One rule, one
|
||||
// place (internal/authz).
|
||||
func ensure(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
var in request
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if in.User == "" || in.Org == "" {
|
||||
return httpx.Err(c, "user and org are required")
|
||||
}
|
||||
switch in.Role {
|
||||
case store.RoleOwner, store.RoleAdmin, store.RoleMember:
|
||||
case "":
|
||||
in.Role = store.RoleMember
|
||||
default:
|
||||
return httpx.Err(c, "role must be owner, admin, or member")
|
||||
}
|
||||
if !mayGrant(ctx, in.Org) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
added, err := store.EnsureMembership(ctx, db, in.User, in.Org, in.Role)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, added)
|
||||
}
|
||||
}
|
||||
|
||||
// remove takes away a person's or an application's right to act in an
|
||||
// organization. Their account survives; what ends is their access to that
|
||||
// organization. Revoking a membership that is already gone reports that nothing
|
||||
// was removed rather than failing, so a retry is safe. It is the mirror of ensure and takes the SAME gate:
|
||||
// revoking membership is the org's authority to give or take, so a SuperAdmin, an
|
||||
// admin of the org itself, or an org-admin-capable confidential client. Idempotent
|
||||
// through the store — deleting an absent membership reports removed=false, never an
|
||||
// error — so a retried revoke is safe.
|
||||
func remove(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
var in request
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if in.User == "" || in.Org == "" {
|
||||
return httpx.Err(c, "user and org are required")
|
||||
}
|
||||
if !mayGrant(ctx, in.Org) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
removed, err := store.DeleteMembership(ctx, db, in.User, in.Org)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, removed)
|
||||
}
|
||||
}
|
||||
|
||||
// mayGrant reports whether the ctx principal may grant OR revoke a membership into
|
||||
// org — the ONE write gate ensure and remove share. Two clauses, both required:
|
||||
//
|
||||
// - the org's admin authority: a SuperAdmin, an admin of the org itself, or an
|
||||
// org-admin-capable confidential client — the same authz.Can(POST, organizations)
|
||||
// gate a write to that org's own registry row takes; AND
|
||||
// - the reserved-org escalation guard (RED F2): a membership INTO a reserved system
|
||||
// org (admin/built-in/app) flows into the target user's `orgs` claim, which the
|
||||
// edge honors as X-Org-Id ∈ orgs — i.e. it seeds admin-org (SuperAdmin) tenancy.
|
||||
// A CapOrgAdmin client passes authz.Can for the membership row (always owned by
|
||||
// the reserved "admin" org, so the check is NOT bound to in.Org), so without this
|
||||
// a brand console could grant anyone tenancy in the admin org. Only a real
|
||||
// SuperAdmin may target a reserved org.
|
||||
func mayGrant(ctx context.Context, org string) bool {
|
||||
if store.IsReservedOrg(org) && !authz.IsSuper(ctx) {
|
||||
return false
|
||||
}
|
||||
return authz.Can(ctx, "POST", "organizations", store.MembershipOwner, org)
|
||||
}
|
||||
|
||||
// scoped reports whether the caller may read the membership rows of org — i.e.
|
||||
// whether resolving the scope from its own verified credential yields exactly
|
||||
// the org it asked for. A SuperAdmin gets what it asks for; anyone else gets its
|
||||
// own org, so any other request fails the equality and is refused.
|
||||
func scoped(ctx context.Context, org string) bool {
|
||||
got, err := authz.Scope(ctx, org)
|
||||
return err == nil && got == org
|
||||
}
|
||||
|
||||
// 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.Bad(400, err.Error(), ""), nil
|
||||
}
|
||||
return httpx.Good(rows, len(rows)), nil
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package memberships_test
|
||||
|
||||
// The the legacy surface membership VERB aliases (GAP A): get-memberships / add-membership /
|
||||
// delete-membership, the spellings cloud's clients/team invite path hard-codes.
|
||||
// Every case is a HTTP request driven through the REAL registered router (routes.Route
|
||||
// installs the authz Guard, then registers memberships after it), so the assertions
|
||||
// prove the three things a backend swap depends on: the verbs reach the SAME store
|
||||
// as the REST surface, the SAME tenant authz gates the REST surface uses, and a
|
||||
// cross-tenant caller is refused with v1's verbatim message.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo"
|
||||
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "memberships.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
seedCert(t, db, "admin", signingKid, pemOf(t, key))
|
||||
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
|
||||
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
|
||||
|
||||
app := zip.New(zip.Config{AppName: "memberships-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = signingKid
|
||||
s, err := tok.SignedString(h.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *harness) get(t *testing.T, path, bearer string) (int, env) {
|
||||
t.Helper()
|
||||
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) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
return h.do(t, req)
|
||||
}
|
||||
|
||||
// postBasic drives an add/delete verb authenticating as a confidential client
|
||||
// (client_secret_basic) — how a brand console / cloud service calls these verbs.
|
||||
func (h *harness) postBasic(t *testing.T, path string, body any, clientID, secret string) (int, env) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(clientID, secret)
|
||||
return h.do(t, req)
|
||||
}
|
||||
|
||||
// do drives the request through the real registered router and decodes the v1
|
||||
// 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 {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
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.
|
||||
type env struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Data2 json.RawMessage `json:"data2"`
|
||||
}
|
||||
|
||||
// ---- cases -----------------------------------------------------------------
|
||||
|
||||
// get-memberships?user=<owner/name> lists one identity's orgs (SuperAdmin path).
|
||||
func TestGetMemberships_byUser(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
|
||||
seedMembership(t, h.db, "hanzo/alice", "team-x", store.RoleAdmin)
|
||||
|
||||
status, e := h.get(t, "/v1/iam/get-memberships?user=hanzo/alice", h.token(t, "admin/root"))
|
||||
if status != 200 || e.Status != "ok" {
|
||||
t.Fatalf("get-memberships?user status=%d env=%+v, want 200 ok", status, e)
|
||||
}
|
||||
rows := parseMemberships(t, e)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("alice acts in %d orgs, want 2 (hanzo, team-x)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// get-memberships?org=<slug> lists an org's roster.
|
||||
func TestGetMemberships_byOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedMembership(t, h.db, "hanzo/alice", "hanzo", store.RoleMember)
|
||||
seedMembership(t, h.db, "hanzo/boss", "hanzo", store.RoleAdmin)
|
||||
|
||||
// hanzo's own admin may read its own org's roster (handler-authorized scoped()).
|
||||
status, e := h.get(t, "/v1/iam/get-memberships?org=hanzo", h.token(t, "hanzo/boss"))
|
||||
if status != 200 || e.Status != "ok" {
|
||||
t.Fatalf("get-memberships?org status=%d env=%+v, want 200 ok", status, e)
|
||||
}
|
||||
if rows := parseMemberships(t, e); len(rows) != 2 {
|
||||
t.Fatalf("hanzo roster = %d, want 2 (alice, boss)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// add-membership creates the row the same store EnsureMembership does, and a
|
||||
// following get-memberships shows it — the verbs share ONE store.
|
||||
func TestAddMembership_thenGetShowsIt(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
super := h.token(t, "admin/root")
|
||||
|
||||
status, e := h.post(t, "/v1/iam/add-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "team-x", "role": "admin"}, super)
|
||||
if status != 200 || e.Status != "ok" {
|
||||
t.Fatalf("add-membership status=%d env=%+v, want 200 ok", status, e)
|
||||
}
|
||||
if !parseBool(t, e) {
|
||||
t.Fatal("add-membership reported no row created")
|
||||
}
|
||||
|
||||
_, g := h.get(t, "/v1/iam/get-memberships?user=hanzo/alice", super)
|
||||
rows := parseMemberships(t, g)
|
||||
if len(rows) != 1 || rows[0].Org != "team-x" || rows[0].Role != store.RoleAdmin {
|
||||
t.Fatalf("after add, memberships = %+v, want one {team-x, admin}", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// delete-membership removes the row and is idempotent: a second delete of the same
|
||||
// (user, org) reports removed=false with no error.
|
||||
func TestDeleteMembership_removesAndIdempotent(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
super := h.token(t, "admin/root")
|
||||
seedMembership(t, h.db, "hanzo/alice", "team-x", store.RoleAdmin)
|
||||
|
||||
status, e := h.post(t, "/v1/iam/delete-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "team-x"}, super)
|
||||
if status != 200 || e.Status != "ok" || !parseBool(t, e) {
|
||||
t.Fatalf("first delete status=%d env=%+v, want 200 ok removed=true", status, e)
|
||||
}
|
||||
// Row is gone.
|
||||
if m, _ := store.GetMembership(context.Background(), h.db, "hanzo/alice", "team-x"); m != nil {
|
||||
t.Fatal("membership survived delete")
|
||||
}
|
||||
// Idempotent second delete: still ok, but removed=false.
|
||||
_, e2 := h.post(t, "/v1/iam/delete-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "team-x"}, super)
|
||||
if e2.Status != "ok" || parseBool(t, e2) {
|
||||
t.Fatalf("second delete env=%+v, want ok removed=false (idempotent)", e2)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-tenant caller is refused with v1's verbatim message — neither writing nor
|
||||
// reading another tenant's membership rows.
|
||||
func TestMembership_crossTenantDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss") // admin of hanzo, NOT of orgb
|
||||
|
||||
// Write into orgb: refused.
|
||||
_, add := h.post(t, "/v1/iam/add-membership",
|
||||
map[string]string{"user": "orgb/bob", "org": "orgb", "role": "member"}, boss)
|
||||
if add.Status != "error" || add.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("cross-tenant add-membership env=%+v, want error auth:Unauthorized operation", add)
|
||||
}
|
||||
// Delete from orgb: refused the same way.
|
||||
_, del := h.post(t, "/v1/iam/delete-membership",
|
||||
map[string]string{"user": "orgb/bob", "org": "orgb"}, boss)
|
||||
if del.Status != "error" || del.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("cross-tenant delete-membership env=%+v, want error auth:Unauthorized operation", del)
|
||||
}
|
||||
// Read orgb's roster: refused the same way.
|
||||
_, roster := h.get(t, "/v1/iam/get-memberships?org=orgb", boss)
|
||||
if roster.Status != "error" || roster.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("cross-tenant get-memberships?org=orgb env=%+v, want error auth:Unauthorized operation", roster)
|
||||
}
|
||||
}
|
||||
|
||||
// RED F2 — a CapOrgAdmin (non-super) confidential client can create customer-org
|
||||
// memberships but must NEVER grant tenancy INTO a reserved system org (admin /
|
||||
// built-in), which would seed a SuperAdmin-org `orgs` claim on the target user. Only
|
||||
// a real SuperAdmin may. The client's legitimate power over a normal org is intact.
|
||||
func TestEnsureMembership_reservedOrgRequiresSuper(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
seedClientApp(t, h.db, "hanzo-console", "console-secret")
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
|
||||
|
||||
// Into the reserved admin/built-in orgs: refused, verbatim.
|
||||
for _, org := range []string{"admin", "built-in"} {
|
||||
_, e := h.postBasic(t, "/v1/iam/add-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": org, "role": "admin"}, "hanzo-console", "console-secret")
|
||||
if e.Status != "error" || e.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("CapOrgAdmin ensure into %q env=%+v, want error auth:Unauthorized operation", org, e)
|
||||
}
|
||||
if m, _ := store.GetMembership(context.Background(), h.db, "hanzo/alice", org); m != nil {
|
||||
t.Fatalf("a reserved-org membership was created in %q despite the refusal", org)
|
||||
}
|
||||
}
|
||||
// Revoke into a reserved org is gated the same way.
|
||||
_, del := h.postBasic(t, "/v1/iam/delete-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "admin"}, "hanzo-console", "console-secret")
|
||||
if del.Status != "error" || del.Msg != "auth:Unauthorized operation" {
|
||||
t.Fatalf("CapOrgAdmin revoke into admin env=%+v, want error auth:Unauthorized operation", del)
|
||||
}
|
||||
|
||||
// Legit power preserved: the SAME client CAN ensure into a normal customer org.
|
||||
_, ok := h.postBasic(t, "/v1/iam/add-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "hanzo", "role": "member"}, "hanzo-console", "console-secret")
|
||||
if ok.Status != "ok" {
|
||||
t.Fatalf("CapOrgAdmin ensure into a normal org env=%+v, want ok (legit power broken)", ok)
|
||||
}
|
||||
|
||||
// And a real SuperAdmin MAY grant a reserved-org membership (the escape hatch).
|
||||
_, sup := h.post(t, "/v1/iam/add-membership",
|
||||
map[string]string{"user": "hanzo/alice", "org": "admin", "role": "admin"}, h.token(t, "admin/root"))
|
||||
if sup.Status != "ok" {
|
||||
t.Fatalf("SuperAdmin ensure into admin env=%+v, want ok", sup)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
if status, _ := h.get(t, "/v1/iam/get-memberships?org=hanzo", ""); status != 401 {
|
||||
t.Fatalf("unauthenticated get-memberships status=%d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func parseMemberships(t *testing.T, e env) []schema.Membership {
|
||||
t.Helper()
|
||||
var rows []schema.Membership
|
||||
if err := json.Unmarshal(e.Data, &rows); err != nil {
|
||||
t.Fatalf("data is not a membership list: %v (data=%s)", err, e.Data)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func parseBool(t *testing.T, e env) bool {
|
||||
t.Helper()
|
||||
var b bool
|
||||
if err := json.Unmarshal(e.Data, &b); err != nil {
|
||||
t.Fatalf("data is not a bool: %v (data=%s)", err, e.Data)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func seedMembership(t *testing.T, db orm.DB, user, org, role string) {
|
||||
t.Helper()
|
||||
if _, err := store.EnsureMembership(context.Background(), db, user, org, role); err != nil {
|
||||
t.Fatalf("seed membership %s@%s: %v", user, org, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin = admin
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedClientApp seeds an admin-owned confidential client (so the CapOrgAdmin
|
||||
// owner-pin holds) with a client_secret for Basic-auth authentication.
|
||||
func seedClientApp(t *testing.T, db orm.DB, name, secret string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "admin", name
|
||||
a.Organization = "hanzo"
|
||||
a.ClientId = name
|
||||
a.ClientSecret = secret
|
||||
a.SetId("admin/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed client app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package memberships
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
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).",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/delete-membership", zip.Doc{
|
||||
Description: "Takes away a person's or an application's right to act in an\norganization. Their account survives; what ends is their access to that\norganization. Revoking a membership that is already gone reports that nothing\nwas removed rather than failing, so a retry is safe. It is the mirror of ensure and takes the SAME gate:\nrevoking membership is the org's authority to give or take, so a SuperAdmin, an\nadmin of the org itself, or an org-admin-capable confidential client. Idempotent\nthrough the store — deleting an absent membership reports removed=false, never an\nerror — so a retried revoke is safe.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/memberships", 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).",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package factor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Package factor is the pure multi-factor DOMAIN — what a factor IS, whether a
|
||||
// passcode verifies, which factors a user has, whether the org demands one, and
|
||||
// how that state is written. It is the ONE implementation both the enrollment
|
||||
// surface (internal/mfa) and the login-time second-factor gate (internal/oidc)
|
||||
// call, so the Verify the challenge runs is the one enrollment's setup check uses
|
||||
// and the Save every MFA write goes through cannot drift apart.
|
||||
//
|
||||
// It is a LEAF: it imports only store + schema, never authz or oidc. That is what
|
||||
// lets the gate (in oidc, which authz imports) use it without an import cycle,
|
||||
// while the enrollment surface (which does need authz) uses it too — one domain,
|
||||
// two callers, no duplication. Radius and push are deliberately absent: no v2
|
||||
// provider transport serves them, and a factor listed as available but unservable
|
||||
// is an unusable challenge.
|
||||
|
||||
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP — the
|
||||
// name is v1's and it is in the serialized payload, so it does not get "improved".
|
||||
const (
|
||||
App = "app"
|
||||
SMS = "sms"
|
||||
Email = "email"
|
||||
)
|
||||
|
||||
// Types lists the factors this package can project, in v1's order. It bounds
|
||||
// AllProps: a factor absent here is never offered on a challenge.
|
||||
var Types = []string{SMS, Email, App}
|
||||
|
||||
// errNoUser is the ONE answer to an unresolvable MFA subject.
|
||||
var errNoUser = errors.New("user doesn't exist")
|
||||
|
||||
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
|
||||
// otpauth:// URL that encodes it, using the RFC 6238 defaults every authenticator
|
||||
// app assumes (the same totp.Generate defaults the enrollment surface uses). It
|
||||
// persists NOTHING: enrollment is stateless and client-held until enable commits
|
||||
// it.
|
||||
func Enroll(userID, issuer string) (secret, url string, err error) {
|
||||
if issuer == "" {
|
||||
issuer = "Hanzo"
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: userID})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// Verify reports whether passcode is currently valid for secret. It is the ONE
|
||||
// TOTP verification point — enrollment's setup check and the login challenge call
|
||||
// this same function, so they cannot drift apart. totp.Validate accepts the
|
||||
// adjacent windows (skew 1), tolerating clock drift.
|
||||
func Verify(secret, passcode string) bool {
|
||||
if secret == "" || passcode == "" {
|
||||
return false
|
||||
}
|
||||
return totp.Validate(passcode, secret)
|
||||
}
|
||||
|
||||
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
|
||||
// characters, the same strength as the TOTP secret it backs up.
|
||||
const recoveryBytes = 20
|
||||
|
||||
// MintRecovery returns one fresh recovery code, in the clear, for the user to write
|
||||
// down. It asks crypto/rand for a secret directly (not a formatted identifier).
|
||||
func MintRecovery() (string, error) {
|
||||
b := make([]byte, recoveryBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
|
||||
// bearer credential verified by equality alone, so — unlike the TOTP secret, which
|
||||
// the verifier needs back in the clear — it hashes like a password.
|
||||
func HashRecovery(plain string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
return string(h), err
|
||||
}
|
||||
|
||||
// HashRecoveryCodes digests each plaintext recovery code for storage — enrollment
|
||||
// hands the user the plaintext (the QR's backup code) exactly once and keeps only
|
||||
// the digest, so a database dump exposes no usable recovery credential.
|
||||
func HashRecoveryCodes(plain []string) ([]string, error) {
|
||||
out := make([]string, 0, len(plain))
|
||||
for _, p := range plain {
|
||||
h, err := HashRecovery(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UseRecovery consumes one of the user's recovery codes, reporting whether code
|
||||
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use — and the
|
||||
// caller persists the row.
|
||||
//
|
||||
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
|
||||
// (object/mfa.go:81 compares in the clear), so a stored value that is not a digest
|
||||
// is compared literally. The algorithm is a property of the stored value, never a
|
||||
// constant — the same rule the password path lives by. A legacy hit is spent and
|
||||
// removed like any other, so the plaintext dies on first use.
|
||||
func UseRecovery(u *schema.User, code string) bool {
|
||||
if u == nil || code == "" {
|
||||
return false
|
||||
}
|
||||
for i, stored := range u.RecoveryCodes {
|
||||
if !recoveryMatches(stored, code) {
|
||||
continue
|
||||
}
|
||||
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recoveryMatches compares one presented code against one stored value, choosing
|
||||
// the comparison from what the value IS: a bcrypt digest is verified with bcrypt,
|
||||
// a v1-era plaintext by equality.
|
||||
func recoveryMatches(stored, code string) bool {
|
||||
if isBcrypt(stored) {
|
||||
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
|
||||
}
|
||||
return stored != "" && stored == code
|
||||
}
|
||||
|
||||
// isBcrypt reports whether s is a bcrypt digest by asking the library's own parser
|
||||
// (bcrypt.Cost), so the answer comes from the format itself rather than a guess.
|
||||
func isBcrypt(s string) bool {
|
||||
_, err := bcrypt.Cost([]byte(s))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
|
||||
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641): the per-factor
|
||||
// enabled flags say which factors exist, not whether the gate runs.
|
||||
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
|
||||
|
||||
// Prompt reports whether the organization REQUIRES a factor the user has not
|
||||
// enrolled yet — the sign-in must divert to enrollment before it can finish. The
|
||||
// user's own MfaItems override the org's entirely when present (not merge: v1
|
||||
// object/organization.go:770-792), so a per-user policy is a replacement.
|
||||
func Prompt(org *schema.Organization, u *schema.User) bool {
|
||||
if org == nil || u == nil {
|
||||
return false
|
||||
}
|
||||
items := org.MfaItems
|
||||
if len(u.MfaItems) > 0 {
|
||||
items = u.MfaItems
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == nil || item.Rule != "Required" {
|
||||
continue
|
||||
}
|
||||
switch item.Name {
|
||||
case Email:
|
||||
if !u.MfaEmailEnabled {
|
||||
return true
|
||||
}
|
||||
case SMS:
|
||||
if !u.MfaPhoneEnabled {
|
||||
return true
|
||||
}
|
||||
case App:
|
||||
if u.TotpSecret == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Props projects one factor of the user for a client, ALWAYS masked: Secret and
|
||||
// RecoveryCodes are never populated (and are json:"-" besides). The login-gate
|
||||
// verifier reads u.TotpSecret directly, so this projection has no unmasked mode to
|
||||
// misuse.
|
||||
func Props(u *schema.User, mfaType string) *schema.MfaProps {
|
||||
p := &schema.MfaProps{MfaType: mfaType}
|
||||
if u == nil {
|
||||
return p
|
||||
}
|
||||
switch mfaType {
|
||||
case SMS:
|
||||
p.Enabled = u.MfaPhoneEnabled
|
||||
if p.Enabled {
|
||||
p.CountryCode = u.CountryCode
|
||||
}
|
||||
case Email:
|
||||
p.Enabled = u.MfaEmailEnabled
|
||||
case App:
|
||||
p.Enabled = u.TotpSecret != ""
|
||||
}
|
||||
if !p.Enabled {
|
||||
return &schema.MfaProps{MfaType: mfaType}
|
||||
}
|
||||
p.IsPreferred = u.PreferredMfaType == mfaType
|
||||
return p
|
||||
}
|
||||
|
||||
// AllProps projects every factor this package serves, masked, in v1's order.
|
||||
func AllProps(u *schema.User) []*schema.MfaProps {
|
||||
all := make([]*schema.MfaProps, 0, len(Types))
|
||||
for _, t := range Types {
|
||||
all = append(all, Props(u, t))
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is the
|
||||
// ONE declaration of which columns ARE multi-factor state, so every writer agrees
|
||||
// on the set by construction: Save overlays a caller's factors onto the STORED row
|
||||
// through this, which is what makes an MFA write column-scoped — the request's user
|
||||
// value never reaches the store, so it cannot carry isAdmin along and self-promote.
|
||||
func Copy(dst, src *schema.User) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.PreferredMfaType = src.PreferredMfaType
|
||||
dst.RecoveryCodes = src.RecoveryCodes
|
||||
dst.TotpSecret = src.TotpSecret
|
||||
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
|
||||
dst.MfaEmailEnabled = src.MfaEmailEnabled
|
||||
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
|
||||
dst.MfaRadiusUsername = src.MfaRadiusUsername
|
||||
dst.MfaRadiusProvider = src.MfaRadiusProvider
|
||||
dst.MfaPushEnabled = src.MfaPushEnabled
|
||||
dst.MfaPushReceiver = src.MfaPushReceiver
|
||||
dst.MfaPushProvider = src.MfaPushProvider
|
||||
dst.MfaRememberDeadline = src.MfaRememberDeadline
|
||||
}
|
||||
|
||||
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It is
|
||||
// the single write point for every MFA mutation the login gate makes: spend a
|
||||
// recovery code, remember a device. The scoping is what makes it safe: the row is
|
||||
// loaded fresh and Copy overlays exactly the multi-factor columns, so an isAdmin,
|
||||
// a balance, or a password digest arriving on an MFA request reaches nothing.
|
||||
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
|
||||
if u == nil {
|
||||
return errNoUser
|
||||
}
|
||||
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored == nil {
|
||||
return errNoUser
|
||||
}
|
||||
Copy(stored, u)
|
||||
return stored.UpdateCtx(ctx)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// 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
|
||||
// disabling a factor and choosing the preferred one. Enrollment is SELF-SERVICE: every handler
|
||||
// acts on the AUTHENTICATED caller's own user record (authz.From), so the routes
|
||||
// register AFTER the Guard — they need the Principal. Touching a DIFFERENT user's
|
||||
// MFA requires admin authority over that org, authorized through the SAME seam a
|
||||
// SCIM write uses (authz.Can); the general user-write policy correctly refuses a
|
||||
// non-admin writing a user row, so self-enrollment is authorized by
|
||||
// self-ownership (target == principal), NOT by that policy.
|
||||
//
|
||||
// The handshake is STATELESS across the three calls: initiate mints a TOTP
|
||||
// secret + otpauth URL + recovery code and hands them to the client; the client
|
||||
// renders the QR, the authenticator app derives a passcode, verify checks it
|
||||
// against the SAME secret the client echoes back, and enable persists the secret
|
||||
// + recovery code to the user. No pending secret is parked server-side between
|
||||
// calls — it is client-held until enable commits it.
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// The TOTP factor type ("app") and the domain helpers are factor.App et al (internal/mfa/factor).
|
||||
|
||||
// Route registers the MFA endpoints on app. They are RAW handlers (not typed
|
||||
// ops), so — like SCIM — each authorizes itself; callers register app AFTER the
|
||||
// Guard so a verified Principal rides the request context.
|
||||
// The MFA surface hangs off the /v1/iam/mfa noun. The two verb-noun spellings it
|
||||
// arrived with stay reachable for pinned consumers and are taught nowhere; see
|
||||
// zip.Alias.
|
||||
const (
|
||||
PathDisable = "/v1/iam/mfa/disable"
|
||||
PathPreferred = "/v1/iam/mfa/preferred"
|
||||
LegacyPathDisable = "/v1/iam/delete-mfa"
|
||||
LegacyPathPreferred = "/v1/iam/set-preferred-mfa"
|
||||
)
|
||||
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
app.Post("/v1/iam/mfa/setup/initiate", initiate(db))
|
||||
app.Post("/v1/iam/mfa/setup/verify", verify(db))
|
||||
app.Post("/v1/iam/mfa/setup/enable", enable(db))
|
||||
zip.Alias(app.Post, PathDisable, LegacyPathDisable, disable(db))
|
||||
zip.Alias(app.Post, PathPreferred, LegacyPathPreferred, setPreferred(db))
|
||||
}
|
||||
|
||||
// setupReq is the union of fields the enrollment handshake posts. owner/name
|
||||
// address the target user (default: the caller itself); secret/passcode/
|
||||
// recoveryCodes carry the client-held enrollment material; mfaType selects the
|
||||
// preferred factor for the preferred-factor endpoint.
|
||||
type setupReq struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Passcode string `json:"passcode"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
MfaType string `json:"mfaType"`
|
||||
}
|
||||
|
||||
// target resolves the (owner, name) an MFA request addresses and authorizes it:
|
||||
// the caller may always manage its OWN record; touching another user's MFA
|
||||
// requires admin authority over that org (authz.Can — the seam SCIM writes use).
|
||||
// An unauthenticated caller fails closed (the Guard already required a bearer, so
|
||||
// this is defense in depth). Returns a zip error to return verbatim on refusal.
|
||||
func target(c *zip.Ctx, req *setupReq) (owner, name string, err error) {
|
||||
p, present := authz.From(c.Context())
|
||||
if !present {
|
||||
return "", "", zip.ErrUnauthorized("authentication required")
|
||||
}
|
||||
owner, name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
|
||||
if owner == "" || name == "" {
|
||||
owner, name = p.Org, p.User // default: the caller itself
|
||||
}
|
||||
self := owner == p.Org && name == p.User
|
||||
if !self && !authz.Can(c.Context(), "PUT", "users", owner, name) {
|
||||
return "", "", zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return owner, name, nil
|
||||
}
|
||||
|
||||
// initiate starts enrolling an authenticator app: it returns a fresh secret, a
|
||||
// URL to render as a QR code, and one recovery code to keep somewhere safe.
|
||||
//
|
||||
// Nothing is switched on yet. The enrolment counts only once it is confirmed with
|
||||
// a code from the app, so abandoning this step leaves the account exactly as it
|
||||
// was. Response:
|
||||
// {status:"ok", data:{secret, url, recoveryCodes:[code]}}.
|
||||
func initiate(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
_ = decode(c, &req) // body optional: owner/name default to the caller
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer(owner), AccountName: name})
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("failed to generate secret"))
|
||||
}
|
||||
code, err := recoveryCode()
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(map[string]any{
|
||||
"secret": key.Secret(),
|
||||
"url": key.URL(),
|
||||
"recoveryCodes": []string{code},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// verify checks a six-digit code against an enrolment in progress, so somebody
|
||||
// can confirm their authenticator app is set up correctly before it starts being
|
||||
// required. Clocks a step out either way are accepted.
|
||||
// A valid code → {status:"ok"}; an invalid one → 200 {status:"error"} (the
|
||||
// casibase convention: clients branch on status, not the HTTP code).
|
||||
func verify(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
if _, _, err := target(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secret == "" || req.Passcode == "" {
|
||||
return c.JSON(200, errResp("secret and passcode are required"))
|
||||
}
|
||||
if !totp.Validate(req.Passcode, req.Secret) {
|
||||
return c.JSON(200, errResp("the code is incorrect"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// enable finishes the enrolment: from here the account's sign-ins ask for a code
|
||||
// from the authenticator app. Repeating it re-enrols rather than failing.
|
||||
func enable(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secret == "" {
|
||||
return c.JSON(200, errResp("secret is required"))
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.TotpSecret = req.Secret
|
||||
hashed, herr := factor.HashRecoveryCodes(req.RecoveryCodes)
|
||||
if herr != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
u.RecoveryCodes = hashed
|
||||
u.PreferredMfaType = factor.App
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(map[string]any{"preferredMfaType": factor.App}))
|
||||
}
|
||||
}
|
||||
|
||||
// disable turns off the authenticator app for an account, so sign-in stops
|
||||
// asking for a code. People may do this for themselves; doing it for somebody
|
||||
// else takes an administrator, which is what makes it the reset path when a
|
||||
// phone is lost.
|
||||
func disable(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
_ = decode(c, &req) // body optional: owner/name default to the caller
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.TotpSecret = ""
|
||||
u.RecoveryCodes = nil
|
||||
u.PreferredMfaType = ""
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// setPreferred picks which second factor an account is asked for first when it
|
||||
// has more than one enrolled.
|
||||
func setPreferred(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.MfaType) == "" {
|
||||
return c.JSON(200, errResp("mfaType is required"))
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.PreferredMfaType = req.MfaType
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func decode(c *zip.Ctx, v any) error {
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return errors.New("empty request body")
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// issuer is the otpauth issuer label the authenticator app shows: an explicit
|
||||
// IAM_MFA_ISSUER override (white-label brand), else the account's org, else Hanzo.
|
||||
func issuer(owner string) string {
|
||||
if v := strings.TrimSpace(os.Getenv("IAM_MFA_ISSUER")); v != "" {
|
||||
return v
|
||||
}
|
||||
if owner != "" {
|
||||
return owner
|
||||
}
|
||||
return "Hanzo"
|
||||
}
|
||||
|
||||
// recoveryCode returns a 160-bit base32 single-use backup code.
|
||||
func recoveryCode() (string, error) {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func okData(data any) map[string]any {
|
||||
m := map[string]any{"status": "ok"}
|
||||
if data != nil {
|
||||
m["data"] = data
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package mfa_test
|
||||
|
||||
// TOTP MFA tests driven through the REAL registered router (routes.Route installs
|
||||
// the Guard, then mfa.Route after it). Every case is a HTTP request the account
|
||||
// security page sends. The assertions pin the enrollment contract (initiate mints
|
||||
// a secret the client can turn into a valid passcode; enable persists it) and the
|
||||
// security one: enrollment is self-service on your OWN record, and a regular user
|
||||
// can NEVER touch another user's MFA — that needs admin authority.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo"
|
||||
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "mfa.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
seedCert(t, db, "admin", signingKid, pemOf(t, key))
|
||||
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
|
||||
|
||||
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = signingKid
|
||||
s, err := tok.SignedString(h.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *harness) do(t *testing.T, path, bearer, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest("POST", path, r)
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := testhttp.Do(h.app, req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
// dataString reads m.data.<key> as a string.
|
||||
func dataString(m map[string]any, key string) string {
|
||||
d, _ := m["data"].(map[string]any)
|
||||
s, _ := d[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestMFA_enrollLifecycle: a regular user enrolls TOTP on her own account —
|
||||
// initiate mints a secret she can turn into a valid passcode, verify accepts it,
|
||||
// enable persists it, disable clears it.
|
||||
func TestMFA_enrollLifecycle(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
|
||||
// initiate — a secret, an otpauth URL, and a recovery code.
|
||||
st, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
|
||||
if st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("initiate: status=%d body=%v", st, m)
|
||||
}
|
||||
secret := dataString(m, "secret")
|
||||
if secret == "" {
|
||||
t.Fatalf("initiate returned no secret: %v", m)
|
||||
}
|
||||
if url := dataString(m, "url"); !strings.HasPrefix(url, "otpauth://totp/") {
|
||||
t.Fatalf("initiate url is not an otpauth URI: %q", url)
|
||||
}
|
||||
d, _ := m["data"].(map[string]any)
|
||||
codes, _ := d["recoveryCodes"].([]any)
|
||||
if len(codes) == 0 || codes[0].(string) == "" {
|
||||
t.Fatalf("initiate returned no recovery code: %v", d)
|
||||
}
|
||||
recovery := codes[0].(string)
|
||||
|
||||
// verify — a code derived from the secret is accepted.
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("totp code: %v", err)
|
||||
}
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/verify", alice,
|
||||
`{"secret":"`+secret+`","passcode":"`+code+`"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("verify valid code: status=%d body=%v", st, m)
|
||||
}
|
||||
|
||||
// enable — the secret + recovery code land on alice's row; TOTP is preferred.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/enable", alice,
|
||||
`{"secret":"`+secret+`","recoveryCodes":["`+recovery+`"]}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("enable: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u == nil || u.TotpSecret != secret {
|
||||
t.Fatalf("enable did not persist TotpSecret: %+v", u)
|
||||
}
|
||||
if u.PreferredMfaType != "app" {
|
||||
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
|
||||
}
|
||||
if len(u.RecoveryCodes) == 0 {
|
||||
t.Fatalf("enable did not persist recovery codes")
|
||||
}
|
||||
|
||||
// disable — every TOTP field is cleared.
|
||||
if st, m := h.do(t, "/v1/iam/delete-mfa", alice, `{}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("disable: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ = store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u.TotpSecret != "" || u.PreferredMfaType != "" || len(u.RecoveryCodes) != 0 {
|
||||
t.Fatalf("disable did not clear MFA fields: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_verifyRejectsBadCode: an incorrect passcode is refused (status:error at
|
||||
// 200 — the casibase convention the console branches on).
|
||||
func TestMFA_verifyRejectsBadCode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
_, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
|
||||
secret := dataString(m, "secret")
|
||||
|
||||
st, body := h.do(t, "/v1/iam/mfa/setup/verify", alice,
|
||||
`{"secret":"`+secret+`","passcode":"000000"}`)
|
||||
if st != 200 || body["status"] != "error" {
|
||||
t.Fatalf("bad code should be rejected: status=%d body=%v", st, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_crossUserRequiresAdmin: a regular user cannot enroll/disable MFA on
|
||||
// ANOTHER user — the general user-write policy refuses it (403). An org-admin and
|
||||
// a super over that user CAN.
|
||||
func TestMFA_crossUserRequiresAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice") // regular
|
||||
boss := h.token(t, "hanzo/boss") // org-admin of hanzo
|
||||
super := h.token(t, "admin/root") // SuperAdmin
|
||||
|
||||
// alice → boss's MFA: forbidden.
|
||||
body := `{"owner":"hanzo","name":"boss"}`
|
||||
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", alice, body); st != 403 {
|
||||
t.Fatalf("regular user initiating another user's MFA: status=%d, want 403", st)
|
||||
}
|
||||
if st, _ := h.do(t, "/v1/iam/delete-mfa", alice, body); st != 403 {
|
||||
t.Fatalf("regular user disabling another user's MFA: status=%d, want 403", st)
|
||||
}
|
||||
|
||||
// org-admin → a user in the SAME org: allowed.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", boss,
|
||||
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("org-admin initiating a same-org user's MFA: status=%d body=%v", st, m)
|
||||
}
|
||||
// super → anyone: allowed.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", super,
|
||||
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("super initiating a user's MFA: status=%d body=%v", st, m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_setPreferred: a user selects a preferred factor on her own account.
|
||||
func TestMFA_setPreferred(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
if st, m := h.do(t, "/v1/iam/set-preferred-mfa", alice, `{"mfaType":"app"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("set-preferred-mfa: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u.PreferredMfaType != "app" {
|
||||
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_requiresBearer: no token → the Guard refuses before the handler.
|
||||
func TestMFA_requiresBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", "", `{}`); st != 401 {
|
||||
t.Fatalf("no-bearer initiate: status=%d, want 401", st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seed helpers (mirror the SCIM harness) ----
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin = admin
|
||||
u.PasswordHash = "$argon2id$SENTINEL"
|
||||
u.PasswordType = "argon2id"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("POST /v1/iam/delete-mfa", zip.Doc{
|
||||
Description: "Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/mfa/disable", zip.Doc{
|
||||
Description: "Turns off the authenticator app for an account, so sign-in stops\nasking for a code. People may do this for themselves; doing it for somebody\nelse takes an administrator, which is what makes it the reset path when a\nphone is lost.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/mfa/preferred", zip.Doc{
|
||||
Description: "Picks which second factor an account is asked for first when it\nhas more than one enrolled.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/mfa/setup/enable", zip.Doc{
|
||||
Description: "Finishes the enrolment: from here the account's sign-ins ask for a code\nfrom the authenticator app. Repeating it re-enrols rather than failing.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/mfa/setup/initiate", zip.Doc{
|
||||
Description: "Starts enrolling an authenticator app: it returns a fresh secret, a\nURL to render as a QR code, and one recovery code to keep somewhere safe.\n\nNothing is switched on yet. The enrolment counts only once it is confirmed with\na code from the app, so abandoning this step leaves the account exactly as it\nwas. Response:\n{status:\"ok\", data:{secret, url, recoveryCodes:[code]}}.",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/mfa/setup/verify", zip.Doc{
|
||||
Description: "Checks a six-digit code against an enrolment in progress, so somebody\ncan confirm their authenticator app is set up correctly before it starts being\nrequired. Clocks a step out either way are accepted.\nA valid code → {status:\"ok\"}; an invalid one → 200 {status:\"error\"} (the\ncasibase convention: clients branch on status, not the HTTP code).",
|
||||
})
|
||||
zip.Describe("POST /v1/iam/set-preferred-mfa", zip.Doc{
|
||||
Description: "Picks which second factor an account is asked for first when it\nhas more than one enrolled.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
|
||||
// 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 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.
|
||||
const hostedLoginPath = "/login/oauth/authorize"
|
||||
|
||||
// authorizeRequest is the parsed authorize query.
|
||||
type authorizeRequest struct {
|
||||
responseType string
|
||||
clientID string
|
||||
redirectURI string
|
||||
scope string
|
||||
state string
|
||||
nonce string
|
||||
codeChallenge string
|
||||
codeChallengeMethod string
|
||||
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.
|
||||
//
|
||||
// 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)
|
||||
|
||||
// 1. Resolve the client. Without a known client there is no trusted
|
||||
// redirect target, so the error is shown in place — never redirected.
|
||||
if q.clientID == "" {
|
||||
return authorizeUserError(c, "client_id is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, q.clientID)
|
||||
if err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
if app == nil {
|
||||
return authorizeUserError(c, "unknown client_id")
|
||||
}
|
||||
// 2. redirect_uri must EXACTLY match a registered URI before it can ever
|
||||
// be used as a redirect target. A mismatch is answered in place.
|
||||
if q.redirectURI == "" || !app.IsRedirectUriValid(q.redirectURI) {
|
||||
return authorizeUserError(c, "invalid redirect_uri")
|
||||
}
|
||||
|
||||
// The redirect target is now trusted: protocol errors redirect back to it
|
||||
// with error+state (RFC 6749 §4.1.2.1).
|
||||
if q.responseType != "code" {
|
||||
return authorizeErrorRedirect(c, q, "unsupported_response_type", "only response_type=code is supported")
|
||||
}
|
||||
method := normalizeChallengeMethod(q.codeChallenge, q.codeChallengeMethod)
|
||||
if q.codeChallenge != "" && method != "S256" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "only S256 PKCE is supported")
|
||||
}
|
||||
if app.ClientSecret == "" && q.codeChallenge == "" {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeParams reads the authorize parameters from the query (GET) or form
|
||||
// body (POST).
|
||||
func authorizeParams(c *zip.Ctx) authorizeRequest {
|
||||
return authorizeRequest{
|
||||
responseType: param(c, "response_type"),
|
||||
clientID: param(c, "client_id"),
|
||||
redirectURI: param(c, "redirect_uri"),
|
||||
scope: param(c, "scope"),
|
||||
state: param(c, "state"),
|
||||
nonce: param(c, "nonce"),
|
||||
codeChallenge: param(c, "code_challenge"),
|
||||
codeChallengeMethod: param(c, "code_challenge_method"),
|
||||
resource: param(c, "resource"),
|
||||
responseMode: param(c, "response_mode"),
|
||||
provider: param(c, "provider"),
|
||||
prompt: param(c, "prompt"),
|
||||
}
|
||||
}
|
||||
|
||||
// hostedLoginTarget is the login URL a validated request is delegated to — the
|
||||
// application's own SigninUrl when set, else the default hosted-login route.
|
||||
func hostedLoginTarget(app *schema.Application) string {
|
||||
if app.SigninUrl != "" {
|
||||
return app.SigninUrl
|
||||
}
|
||||
return hostedLoginPath
|
||||
}
|
||||
|
||||
// authorizeForwardQuery re-encodes the validated request as a clean query string
|
||||
// for the hosted login — reconstructed from known parameters so nothing
|
||||
// unexpected is passed through.
|
||||
func authorizeForwardQuery(q authorizeRequest, method string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", q.clientID)
|
||||
v.Set("redirect_uri", q.redirectURI)
|
||||
setIfPresent(v, "scope", q.scope)
|
||||
setIfPresent(v, "state", q.state)
|
||||
setIfPresent(v, "nonce", q.nonce)
|
||||
if q.codeChallenge != "" {
|
||||
v.Set("code_challenge", q.codeChallenge)
|
||||
v.Set("code_challenge_method", method)
|
||||
}
|
||||
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 := "?"
|
||||
switch {
|
||||
case q.responseMode == "fragment":
|
||||
sep = "#"
|
||||
case strings.Contains(q.redirectURI, "?"):
|
||||
sep = "&"
|
||||
}
|
||||
return c.Redirect(302, q.redirectURI+sep+v.Encode())
|
||||
}
|
||||
|
||||
// authorizeUserError answers a request whose client_id/redirect_uri could not be
|
||||
// validated: the resource owner is informed in place and the request is NOT
|
||||
// redirected anywhere (RFC 6749 §4.1.2.1). The message is server-controlled.
|
||||
func authorizeUserError(c *zip.Ctx, msg string) error {
|
||||
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.String(400, "authorization error: "+msg)
|
||||
}
|
||||
|
||||
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
|
||||
// is present (S256 is the only method iam supports); an explicit non-S256
|
||||
// method is returned unchanged so the caller rejects the downgrade.
|
||||
func normalizeChallengeMethod(challenge, method string) string {
|
||||
if challenge == "" {
|
||||
return method
|
||||
}
|
||||
if method == "" || strings.EqualFold(method, "null") {
|
||||
return "S256"
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// setIfPresent sets a query value only when non-empty.
|
||||
func setIfPresent(v url.Values, key, value string) {
|
||||
if value != "" {
|
||||
v.Set(key, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
const testRedirect = "https://app.example/callback"
|
||||
|
||||
func authorizeURL(q url.Values) string {
|
||||
return PathAuthorize + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// The authorize endpoint validates the client and redirect_uri BEFORE it will
|
||||
// redirect anywhere: an unknown client or an unregistered redirect_uri is
|
||||
// answered in place (never bounced), closing the open-redirect surface.
|
||||
func TestAuthorize_RefusesToRedirectOnBadClientOrRedirect(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q url.Values
|
||||
}{
|
||||
{"missing client_id", url.Values{"response_type": {"code"}, "redirect_uri": {testRedirect}}},
|
||||
{"unknown client_id", url.Values{"response_type": {"code"}, "client_id": {"ghost"}, "redirect_uri": {testRedirect}}},
|
||||
{"missing redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}}},
|
||||
{"unregistered redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {"https://evil.example/steal"}}},
|
||||
{"redirect near-match", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect + "/.."}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(tc.q)))
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
t.Fatalf("must NOT redirect on bad client/redirect; got Location %q", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Once the client + redirect_uri are validated, a protocol error bounces back to
|
||||
// the (trusted) redirect_uri with error + state.
|
||||
func TestAuthorize_ProtocolErrorRedirectsToClient(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
t.Run("unsupported response_type", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"token"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"xyz"}, "code_challenge": {"abc"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=unsupported_response_type") || !strings.Contains(loc, "state=xyz") {
|
||||
t.Fatalf("Location = %q", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public client without PKCE", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"s1"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=invalid_request") {
|
||||
t.Fatalf("public client without PKCE should error; Location = %q", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain PKCE rejected", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_challenge": {"abc"}, "code_challenge_method": {"plain"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=invalid_request") {
|
||||
t.Fatalf("plain PKCE should be rejected; Location = %q", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A well-formed request is delegated to the hosted login with the (re-encoded)
|
||||
// request preserved.
|
||||
func TestAuthorize_DelegatesValidRequest(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
challenge := pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {"pub"},
|
||||
"redirect_uri": {testRedirect},
|
||||
"scope": {"openid profile"},
|
||||
"state": {"state-1"},
|
||||
"nonce": {"nonce-1"},
|
||||
"code_challenge": {challenge},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, hostedLoginPath+"?") {
|
||||
t.Fatalf("Location = %q, want hosted-login delegate", loc)
|
||||
}
|
||||
forwarded, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fq := forwarded.Query()
|
||||
if fq.Get("client_id") != "pub" || fq.Get("redirect_uri") != testRedirect ||
|
||||
fq.Get("code_challenge") != challenge || fq.Get("code_challenge_method") != "S256" ||
|
||||
fq.Get("state") != "state-1" || fq.Get("nonce") != "nonce-1" {
|
||||
t.Fatalf("delegated query missing/incorrect: %v", fq)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"conf"}, "redirect_uri": {testRedirect}, "scope": {"openid"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
if resp.StatusCode != 302 || !strings.HasPrefix(resp.Header.Get("Location"), hostedLoginPath+"?") {
|
||||
t.Fatalf("confidential authorize: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// requireRedirect asserts a 302 whose Location targets wantPrefix and returns it.
|
||||
func requireRedirect(t *testing.T, resp *http.Response, wantPrefix string) string {
|
||||
t.Helper()
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, wantPrefix) {
|
||||
t.Fatalf("Location = %q, want prefix %q", loc, wantPrefix)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
// Canonical noun addresses for the front-door endpoints that were spelled as
|
||||
// verb-nouns.
|
||||
//
|
||||
// A path segment names a THING, and the HTTP method says what is being done to
|
||||
// it. `POST /v1/iam/send-verification-code` says the verb twice and the noun
|
||||
// once; `POST /v1/iam/verification-codes` says each exactly once. The verb-noun
|
||||
// spellings came in with the entity store this service replaced, and they are
|
||||
// what a customer reads in `hanzo iam --help`, in every generated SDK method
|
||||
// name and on every docs page — so they are a customer-facing surface, not an
|
||||
// internal detail.
|
||||
//
|
||||
// Every constant below is the address the published document declares. The old
|
||||
// spelling stays REACHABLE — same handler, registered twice by alias() — so no
|
||||
// consumer pinned to it breaks; it is simply not what anything teaches. When the
|
||||
// last pinned consumer moves, the Legacy* half of a pair is deleted and nothing
|
||||
// else changes.
|
||||
const (
|
||||
PathAccount = "/v1/iam/account" // legacy: get-account
|
||||
PathAuthApplication = "/v1/iam/auth/application" // legacy: get-app-login
|
||||
PathPreferences = "/v1/iam/preferences" // legacy: update-preferences
|
||||
PathVerificationCodes = "/v1/iam/verification-codes" // legacy: send-verification-code
|
||||
PathTokensIssue = "/v1/iam/tokens/issue" // legacy: issue-user-token
|
||||
PathKeysMint = "/v1/iam/keys/mint" // legacy: mint-user-keys
|
||||
PathKeysRevoke = "/v1/iam/keys/revoke" // legacy: revoke-user-keys
|
||||
)
|
||||
|
||||
// The verb-noun spellings these replaced. Kept reachable, taught nowhere.
|
||||
const (
|
||||
LegacyPathAccount = "/v1/iam/get-account"
|
||||
LegacyPathAuthApplication = "/v1/iam/get-app-login"
|
||||
LegacyPathPreferences = "/v1/iam/update-preferences"
|
||||
LegacyPathVerificationCodes = "/v1/iam/send-verification-code"
|
||||
LegacyPathTokensIssue = "/v1/iam/issue-user-token"
|
||||
LegacyPathKeysMint = "/v1/iam/mint-user-keys"
|
||||
LegacyPathKeysRevoke = "/v1/iam/revoke-user-keys"
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every front-door endpoint that used to be spelled as a verb-noun answers at
|
||||
// BOTH its canonical noun address and the legacy spelling, from ONE handler.
|
||||
//
|
||||
// This is the whole contract of alias(): the canonical address is what the
|
||||
// published document, the SDKs and the CLI teach, and the legacy one stays
|
||||
// reachable so a consumer pinned to it does not break. The test that matters is
|
||||
// not "the new address works" — it is that neither address 404s, because a
|
||||
// rename that quietly drops the old spelling is an outage in the console, and a
|
||||
// rename nobody registers is a document that lies.
|
||||
func TestCanonicalAndLegacyAddressesBothRoute(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
for _, tc := range []struct{ method, canonical, legacy string }{
|
||||
{"GET", PathAccount, LegacyPathAccount},
|
||||
{"GET", PathAuthApplication, LegacyPathAuthApplication},
|
||||
{"POST", PathPreferences, LegacyPathPreferences},
|
||||
{"POST", PathVerificationCodes, LegacyPathVerificationCodes},
|
||||
{"POST", PathTokensIssue, LegacyPathTokensIssue},
|
||||
{"POST", PathKeysMint, LegacyPathKeysMint},
|
||||
{"POST", PathKeysRevoke, LegacyPathKeysRevoke},
|
||||
} {
|
||||
for _, path := range []string{tc.canonical, tc.legacy} {
|
||||
resp, _ := do(t, app, formReqNoBody(tc.method, path))
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s -> %d, want any answer but not-routed", tc.method, path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// certkey resolves the PUBLIC half of a signing Cert and encodes it as a JWK.
|
||||
// It is the one place cert → public-key happens, shared by the JWKS endpoint
|
||||
// (which publishes the key so relying parties can verify) and token
|
||||
// verification (which checks a bearer against it). The public key is read from
|
||||
// the Cert's published x509 certificate when present, else derived from the key
|
||||
// pair; private material never crosses this boundary.
|
||||
|
||||
// certPublicKey returns a Cert's public key, its JOSE alg, and (for x509 certs)
|
||||
// the base64 DER chain for the JWK `x5c`. An ML-DSA cert yields a raw ML-DSA
|
||||
// public key and no chain.
|
||||
func certPublicKey(cert *schema.Cert) (pub crypto.PublicKey, alg string, x5c []string, err error) {
|
||||
if cert == nil {
|
||||
return nil, "", nil, errors.New("jwks: nil cert")
|
||||
}
|
||||
if isMLDSACert(cert) {
|
||||
pk, err := mldsa65PublicFromCert(cert)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return pk, algMLDSA65, nil, nil
|
||||
}
|
||||
if cert.Certificate != "" {
|
||||
block, _ := pem.Decode([]byte(cert.Certificate))
|
||||
if block != nil {
|
||||
x509Cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(x509Cert.PublicKey, cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return x509Cert.PublicKey, a, []string{base64.StdEncoding.EncodeToString(x509Cert.Raw)}, nil
|
||||
}
|
||||
}
|
||||
// Dev/test cert that stores only the private key: derive the public half.
|
||||
signer, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(signer.Public(), cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return signer.Public(), a, nil, nil
|
||||
}
|
||||
|
||||
// certToJWK encodes a Cert's public key as a JWK map: {kty, alg, use:"sig", kid,
|
||||
// key params, x5c?}. kid is the Cert name (what token headers carry), matching
|
||||
// the live hanzo.id JWKS.
|
||||
func certToJWK(cert *schema.Cert) (map[string]any, error) {
|
||||
pub, alg, x5c, err := certPublicKey(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jwk map[string]any
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
jwk = rsaJWK(k)
|
||||
case *ecdsa.PublicKey:
|
||||
jwk, err = ecJWK(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *mldsa65.PublicKey:
|
||||
jwk = map[string]any{"kty": "MLDSA", "x": base64.RawURLEncoding.EncodeToString(k.Bytes())}
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
jwk["use"] = "sig"
|
||||
jwk["kid"] = cert.Name
|
||||
jwk["alg"] = alg
|
||||
if len(x5c) > 0 {
|
||||
jwk["x5c"] = x5c
|
||||
}
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
// rsaJWK encodes an RSA public key's modulus and exponent (RFC 7518 §6.3).
|
||||
func rsaJWK(k *rsa.PublicKey) map[string]any {
|
||||
return map[string]any{
|
||||
"kty": "RSA",
|
||||
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
// ecJWK encodes an EC public key's curve and fixed-width coordinates (RFC 7518
|
||||
// §6.2) and returns the curve's JOSE alg.
|
||||
func ecJWK(k *ecdsa.PublicKey) (map[string]any, error) {
|
||||
var crv string
|
||||
var size int
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
crv, size = "P-256", 32
|
||||
case 384:
|
||||
crv, size = "P-384", 48
|
||||
case 521:
|
||||
crv, size = "P-521", 66
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported EC curve")
|
||||
}
|
||||
return map[string]any{
|
||||
"kty": "EC",
|
||||
"crv": crv,
|
||||
"x": base64.RawURLEncoding.EncodeToString(leftPad(k.X.Bytes(), size)),
|
||||
"y": base64.RawURLEncoding.EncodeToString(leftPad(k.Y.Bytes(), size)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classicalAlg maps a classical public key (and the Cert's declared algorithm,
|
||||
// when it agrees with the key family) to a JOSE alg. The key type is
|
||||
// authoritative; the declared value only refines RSA (RS256 default, RS512 when
|
||||
// pinned).
|
||||
func classicalAlg(pub crypto.PublicKey, declared string) (string, error) {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
if strings.EqualFold(declared, "RS512") {
|
||||
return "RS512", nil
|
||||
}
|
||||
return "RS256", nil
|
||||
case *ecdsa.PublicKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return "ES256", nil
|
||||
case 384:
|
||||
return "ES384", nil
|
||||
case 521:
|
||||
return "ES512", nil
|
||||
}
|
||||
return "", errors.New("jwks: unsupported EC curve")
|
||||
default:
|
||||
return "", errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
}
|
||||
|
||||
// leftPad left-zero-pads b to size bytes (EC coordinates are fixed-width).
|
||||
func leftPad(b []byte, size int) []byte {
|
||||
if len(b) >= size {
|
||||
return b
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out[size-len(b):], b)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// The login-challenge lifecycle: the ONE primitive for a sign-in that has proven
|
||||
// one thing and must prove another before a token exists. The MFA gate mints one
|
||||
// when a password verifies but the second factor is outstanding; the matching
|
||||
// finish takes it.
|
||||
//
|
||||
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
|
||||
// the state is a server-side row (schema.LoginChallenge) and the client holds only
|
||||
// its opaque id. It is a SIBLING of Token, never a Token with borrowed fields:
|
||||
// /token resolves a grant by Code, so a challenge filed there would sit on the
|
||||
// redemption path wearing a fictional Application.
|
||||
|
||||
// challengeTTL bounds a half-finished ceremony. Five minutes is the authorization
|
||||
// code's own bound — long enough to read a code off a phone, short enough that an
|
||||
// abandoned challenge is not a standing key to an account whose password is
|
||||
// already known.
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// The challenge kinds. Each names the proof still outstanding, and a taker demands
|
||||
// its own kind: a challenge minted for one purpose must never satisfy another.
|
||||
const (
|
||||
KindMfa = "mfa"
|
||||
KindFederation = "federation"
|
||||
)
|
||||
|
||||
// ErrChallenge is the ONE opaque failure for every way a challenge can be refused
|
||||
// — unknown, expired, spent, or the wrong kind. They collapse to one answer so a
|
||||
// prober cannot tell a spent challenge from a forged one.
|
||||
var ErrChallenge = errors.New("the multi-factor session has expired")
|
||||
|
||||
// challengeOwner files every challenge under the reserved admin org. A challenge
|
||||
// is the authorization server's own state, not a tenant record: it is never
|
||||
// listed, never served by an entity route, and its subject is the only tenancy
|
||||
// that matters (and rides inside it, verified).
|
||||
const challengeOwner = "admin"
|
||||
|
||||
// MintChallenge persists a fresh challenge for subject ("owner/name") and returns
|
||||
// its opaque id. payload is the kind's own state — the just-used verification type
|
||||
// for the MFA gate. now is injected for testability.
|
||||
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
|
||||
id, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c := orm.New[schema.LoginChallenge](db)
|
||||
c.Owner = challengeOwner
|
||||
c.Name = id
|
||||
c.CreatedTime = now.UTC().Format(time.RFC3339)
|
||||
c.Kind = kind
|
||||
c.Subject = subject
|
||||
c.Payload = payload
|
||||
c.ExpireIn = now.Add(challengeTTL).Unix()
|
||||
c.SetId(challengeOwner + "/" + id)
|
||||
if err := c.CreateCtx(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// TakeChallenge resolves and atomically SPENDS a challenge of the given kind,
|
||||
// returning it. The find-and-burn runs inside a GetForUpdate transaction — the row
|
||||
// lock is held from the read through the Used=true write — so two concurrent
|
||||
// finishMfa calls on ONE captured passcode cannot both observe Used=false and both
|
||||
// win: the loser blocks until the winner commits, then reads it spent. A plain
|
||||
// Get→set→Update would leave a window in which both pass the used check (the F-D1
|
||||
// lost-update/TOCTOU class); this is the same guard as the wallet challenge burn
|
||||
// (internal/wallet/store.go). The caller gets the subject from the returned row and
|
||||
// nowhere else — never from a request parameter, so a body naming another user
|
||||
// cannot redirect the ceremony.
|
||||
//
|
||||
// Every refusal — unknown, expired, spent, wrong kind, or a transient store fault —
|
||||
// collapses to ErrChallenge, so a prober cannot tell them apart.
|
||||
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.LoginChallenge, error) {
|
||||
if id == "" {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
var out *schema.LoginChallenge
|
||||
err := db.RunInTransaction(ctx, func(tx orm.DB) error {
|
||||
c, err := orm.GetForUpdate[schema.LoginChallenge](tx, challengeOwner+"/"+id)
|
||||
if err != nil {
|
||||
return ErrChallenge // unknown id (ErrNotFound) or a transient read fault
|
||||
}
|
||||
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
|
||||
return ErrChallenge // spent, wrong kind, or expired
|
||||
}
|
||||
c.Used = true
|
||||
if err := c.UpdateCtx(ctx); err != nil {
|
||||
return ErrChallenge
|
||||
}
|
||||
out = c
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// challengeCookie carries the challenge id to the client exactly the way v1 carries
|
||||
// its beego session: a host-only, HttpOnly cookie the browser returns on the
|
||||
// finishing request. Script cannot read it; it is bound to the ceremony's own
|
||||
// short life.
|
||||
const challengeCookie = "hanzo_challenge"
|
||||
|
||||
// SetChallenge writes the challenge id for the finishing request to return.
|
||||
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
|
||||
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
|
||||
// browser forgets it exactly when the server does.
|
||||
func SetChallenge(c *zip.Ctx, id string) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: int(challengeTTL / time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearChallenge expires the cookie once its challenge is spent, so a finished
|
||||
// ceremony leaves nothing behind to replay.
|
||||
func ClearChallenge(c *zip.Ctx) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ReadChallenge returns the challenge id a finishing request presents: the body
|
||||
// field when one is given (an SDK holding no cookie jar), else the cookie the
|
||||
// browser returned. ONE function, ONE precedence — the id is the bearer of the
|
||||
// ceremony either way, and the row it names is single-use, short-lived, and
|
||||
// carries its own subject, so neither source can widen what it proves.
|
||||
func ReadChallenge(c *zip.Ctx, fromBody string) string {
|
||||
if fromBody != "" {
|
||||
return fromBody
|
||||
}
|
||||
return c.Fiber().Cookies(challengeCookie)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// ITEM 4: TakeChallenge burns a login challenge exactly once. A captured MFA passcode
|
||||
// rides on ONE challenge id; if two concurrent finishMfa calls both observe Used=false
|
||||
// and both mark it used, the passcode is double-spent (the F-D1 lost-update/TOCTOU
|
||||
// class). The burn runs inside a GetForUpdate transaction, so exactly one caller wins.
|
||||
|
||||
func TestTakeChallenge_concurrentBurn_exactlyOneWinner(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := tctx()
|
||||
now := time.Now()
|
||||
|
||||
id, err := MintChallenge(ctx, db, KindMfa, "hanzo/alice", "", now)
|
||||
if err != nil {
|
||||
t.Fatalf("mint challenge: %v", err)
|
||||
}
|
||||
|
||||
const N = 16
|
||||
var wins int64
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if ch, err := TakeChallenge(ctx, db, id, KindMfa, now); err == nil && ch != nil {
|
||||
atomic.AddInt64(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("concurrent TakeChallenge on one id produced %d winners, want exactly 1 — a captured passcode can be double-spent (ITEM 4)", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// I1: BurnFederationState consumes an in-flight federation transaction exactly once —
|
||||
// the OAuth-callback single-use guard, the exact twin of TakeChallenge. Two concurrent
|
||||
// callbacks on one `state` must not both flip Used=false→true (double-completion of the
|
||||
// same federated login). The burn runs inside a GetForUpdate transaction, so exactly one
|
||||
// wins.
|
||||
func TestBurnFederationState_concurrentBurn_exactlyOneWinner(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := tctx()
|
||||
now := time.Now()
|
||||
|
||||
const state = "fedstate-0123456789abcdef0123456789abcdef" // opaque state token = row Name
|
||||
if err := store.PersistFederationState(ctx, db, &schema.FederationState{
|
||||
Owner: "admin",
|
||||
Name: state,
|
||||
ExpireIn: now.Add(5 * time.Minute).Unix(),
|
||||
}); err != nil {
|
||||
t.Fatalf("persist federation state: %v", err)
|
||||
}
|
||||
|
||||
const N = 16
|
||||
var wins int64
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if st, err := store.BurnFederationState(ctx, db, state, now); err == nil && st != nil {
|
||||
atomic.AddInt64(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("concurrent BurnFederationState on one state produced %d winners, want exactly 1 — a federation callback can be double-completed (I1)", wins)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// Authorization-code lifecycle over the Token entity. A code is a short-lived,
|
||||
// single-use bearer of the right to mint tokens for one (app, user); PKCE binds
|
||||
// it to the client instance that started the flow, and the single-use + expiry
|
||||
// guards close replay.
|
||||
|
||||
// codeTTL bounds how long an authorization code is redeemable (RFC 6749 §4.1.2
|
||||
// recommends ≤ 10 min; we use 5).
|
||||
const codeTTL = 5 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrCodeUnknown — no token row carries this code.
|
||||
ErrCodeUnknown = errors.New("oauth: authorization code not found")
|
||||
// ErrCodeUsed — the code was already redeemed (replay). Per RFC 6749 §4.1.2
|
||||
// a reused code SHOULD also revoke previously-issued tokens; the caller does
|
||||
// that when it detects this error.
|
||||
ErrCodeUsed = errors.New("oauth: authorization code already used")
|
||||
// ErrCodeExpired — the code is past its TTL.
|
||||
ErrCodeExpired = errors.New("oauth: authorization code expired")
|
||||
// ErrClientMismatch — the redeeming client_id is not the one the code was
|
||||
// minted for.
|
||||
ErrClientMismatch = errors.New("oauth: client_id does not match the authorization code")
|
||||
)
|
||||
|
||||
// newOpaqueToken returns a 256-bit URL-safe random token (code / access token).
|
||||
func newOpaqueToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// MintCode builds (does not persist) a Token row representing a fresh
|
||||
// authorization code bound to (app, user), the PKCE challenge, scope, and
|
||||
// resource. The caller persists it via the store. now is injected for
|
||||
// testability.
|
||||
func MintCode(app *schema.Application, userID, scope, challenge, method, resource string, now time.Time) (*schema.Token, error) {
|
||||
code, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If a challenge is present, pin the method to S256 — never store "plain".
|
||||
if challenge != "" && method != "S256" {
|
||||
return nil, ErrPKCEPlainRejected
|
||||
}
|
||||
// The token row is keyed by the application's OWNER (its registry owner, e.g.
|
||||
// "admin"), so (Owner, Application) is the application's natural key and the
|
||||
// token endpoint resolves the app back unambiguously. Organization records the
|
||||
// tenant the grant belongs to.
|
||||
return &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Organization: app.Organization,
|
||||
Application: app.Name,
|
||||
User: userID,
|
||||
Code: code,
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
CodeChallenge: challenge,
|
||||
CodeChallengeMethod: method,
|
||||
CodeIsUsed: false,
|
||||
CodeExpireIn: now.Add(codeTTL).Unix(),
|
||||
Resource: resource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RedeemCode validates an authorization_code exchange against the stored token
|
||||
// row and returns nil iff the code may be used. It is the single guard the
|
||||
// token endpoint calls; on success the caller MUST immediately mark the row used
|
||||
// (MarkUsed) inside the same transaction so a concurrent replay loses.
|
||||
//
|
||||
// Checks, in order (each fail-closed):
|
||||
// 1. row exists (caller passes nil → ErrCodeUnknown)
|
||||
// 2. not already used (replay)
|
||||
// 3. not expired
|
||||
// 4. client_id matches (constant-time)
|
||||
// 5. PKCE: verifier derives the stored challenge (S256; plain refused; a public
|
||||
// client that stored a challenge must present a verifier)
|
||||
func RedeemCode(tok *schema.Token, clientAppName, verifier string, now time.Time) error {
|
||||
if tok == nil {
|
||||
return ErrCodeUnknown
|
||||
}
|
||||
if tok.CodeIsUsed {
|
||||
return ErrCodeUsed
|
||||
}
|
||||
if tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(tok.Application), []byte(clientAppName)) != 1 {
|
||||
return ErrClientMismatch
|
||||
}
|
||||
return VerifyPKCE(verifier, tok.CodeChallenge, tok.CodeChallengeMethod)
|
||||
}
|
||||
|
||||
// IssueAccessToken fills the row with a freshly-minted access token + expiry and
|
||||
// marks the code used — the atomic success step after RedeemCode. now injected
|
||||
// for tests. ttlSeconds is the access-token lifetime.
|
||||
func IssueAccessToken(tok *schema.Token, ttlSeconds int, now time.Time) error {
|
||||
at, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok.AccessToken = at
|
||||
tok.ExpiresIn = ttlSeconds
|
||||
tok.CodeIsUsed = true // one-shot: any subsequent RedeemCode → ErrCodeUsed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
func testApp() *schema.Application {
|
||||
a := &schema.Application{Organization: "hanzo"}
|
||||
a.Name = "hanzo-console"
|
||||
a.ClientId = "hanzo-console"
|
||||
return a
|
||||
}
|
||||
|
||||
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-abc-000000000000000000000000000000000"
|
||||
ch := pkce.Challenge(verifier)
|
||||
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Code == "" || len(tok.Code) < 40 {
|
||||
t.Fatalf("code not a 256-bit token: %q", tok.Code)
|
||||
}
|
||||
if tok.CodeIsUsed {
|
||||
t.Fatal("fresh code must not be used")
|
||||
}
|
||||
if tok.CodeExpireIn != now.Add(codeTTL).Unix() {
|
||||
t.Fatalf("expiry = %d, want %d", tok.CodeExpireIn, now.Add(codeTTL).Unix())
|
||||
}
|
||||
if tok.Application != "hanzo-console" || tok.User != "hanzo/alice" {
|
||||
t.Fatalf("binding wrong: app=%q user=%q", tok.Application, tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCode_RefusesPlain(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
if _, err := MintCode(testApp(), "u", "", "some-challenge", "plain", "", now); !errors.Is(err, ErrPKCEPlainRejected) {
|
||||
t.Fatalf("mint with plain: got %v, want ErrPKCEPlainRejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-happy-0000000000000000000000000000000"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-replay-000000000000000000000000000000"
|
||||
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)
|
||||
}
|
||||
if err := IssueAccessToken(tok, 3600, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replay must now fail.
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now); !errors.Is(err, ErrCodeUsed) {
|
||||
t.Fatalf("replay: got %v, want ErrCodeUsed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-exp-00000000000000000000000000000000000"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-cli-00000000000000000000000000000000000"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_UnknownCode(t *testing.T) {
|
||||
if err := RedeemCode(nil, "hanzo-console", "v", time.Now()); !errors.Is(err, ErrCodeUnknown) {
|
||||
t.Fatalf("nil token: got %v, want ErrCodeUnknown", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessToken_MintsAndMarksUsed(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", "", "", "", now)
|
||||
if err := IssueAccessToken(tok, 3600, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.AccessToken == "" || len(tok.AccessToken) < 40 {
|
||||
t.Fatalf("access token not minted: %q", tok.AccessToken)
|
||||
}
|
||||
if !tok.CodeIsUsed {
|
||||
t.Fatal("code must be marked used after issue")
|
||||
}
|
||||
if tok.ExpiresIn != 3600 {
|
||||
t.Fatalf("expiresIn = %d, want 3600", tok.ExpiresIn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// GET/PUT /v1/iam/consent — the account-canonical data-sharing consent: the ONE
|
||||
// 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).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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"
|
||||
|
||||
// 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 — 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()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil || user == nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
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.
|
||||
//
|
||||
// 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()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
var in consentBody
|
||||
if err := json.Unmarshal(c.Fiber().Body(), &in); err != nil {
|
||||
return httpx.Err(c, "consent must be a JSON object")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
u.UpdatedTime = provisionNow()
|
||||
// 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, 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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/sessions"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// The RFC 8628 device authorization grant: how a machine with no browser and no
|
||||
// keyboard signs in (`hanzo login` on a GPU box, over ssh, in CI). Three legs,
|
||||
// each landing on an EXISTING seam rather than a parallel stack:
|
||||
//
|
||||
// 1. POST /v1/iam/oauth/device — the device asks for a device_code + a short
|
||||
// user_code and shows the human a verification URI.
|
||||
// 2. POST /v1/iam/login {type:"device"} — the human, on any other machine,
|
||||
// proves who they are and approves the user_code (login.go).
|
||||
// 3. POST /v1/iam/oauth/token grant_type=…:device_code — the device polls and
|
||||
// mints through issueTokens, the same path every other grant mints through.
|
||||
//
|
||||
// A device authorization IS a pending authorization code, so it is a Token row
|
||||
// (Code=device_code, UserCode=user_code, User empty until approved) — not a
|
||||
// process-local map, which would die on restart and never work across replicas.
|
||||
//
|
||||
// Client authentication follows RFC 8628 §3.1 (request) and §3.4 (poll), which
|
||||
// both defer to RFC 6749 §3.2.1: a CONFIDENTIAL client (one with a registered
|
||||
// secret) authenticates at both legs exactly as it would at the token endpoint;
|
||||
// a PUBLIC device client (no secret — the usual CLI) is bound by its client_id
|
||||
// alone. The verification_uri page a human opens is public; the JSON legs here
|
||||
// are not a browser surface.
|
||||
|
||||
// The device grant's vocabulary. deviceCodeTTL and devicePollInterval are each
|
||||
// read by the device request, the poll, and Discovery, so the lifetime a client
|
||||
// is told and the lifetime enforced can never drift.
|
||||
const (
|
||||
// deviceGrant is the RFC 8628 grant_type identifier.
|
||||
deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
// deviceCodeTTL bounds a device_code/user_code pair: long enough for a human
|
||||
// to open the link on a phone, sign in, and approve. It is deliberately NOT
|
||||
// codeTTL (5 min) — an authorization code is redeemed by software in seconds,
|
||||
// a device code waits on a person.
|
||||
deviceCodeTTL = 15 * time.Minute
|
||||
// devicePollInterval is the minimum seconds between token-endpoint polls
|
||||
// (RFC 8628 §3.5 `interval`).
|
||||
devicePollInterval = 5
|
||||
)
|
||||
|
||||
// user_code generation. The alphabet is RFC 8628 §6.1 "unambiguous": no I, L, O,
|
||||
// 0 or 1, because a human reads this off one screen and types it into another.
|
||||
// Its 32 symbols make the 5-bit mask below a UNIFORM draw — a modulo over a
|
||||
// non-power-of-two alphabet would bias the code and cost entropy — so 8
|
||||
// characters carry a full 40 bits. The live portal normalizes a typed code to
|
||||
// exactly this alphabet, uppercasing and stripping separators
|
||||
// (id pkgs/auth/src/client.ts normalizeUserCode), so the minted code is the
|
||||
// canonical form: uppercase, no dashes.
|
||||
const (
|
||||
userCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
userCodeLen = 8
|
||||
userCodeTries = 5
|
||||
)
|
||||
|
||||
// errUserCodeExhausted — every generated user_code collided with a live one.
|
||||
// Astronomically unlikely (40 bits against the handful of pending codes); it
|
||||
// fails closed rather than reusing a code.
|
||||
var errUserCodeExhausted = errors.New("device: could not generate a free user_code")
|
||||
|
||||
// deviceResponse is the RFC 8628 §3.2 device authorization response. The field
|
||||
// names are load-bearing: both CLIs decode exactly this shape and hard-fail on
|
||||
// an empty device_code/user_code (cloud/cli/device.go, codex-rs
|
||||
// login/src/oidc_device_auth.rs).
|
||||
type deviceResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationUri string `json:"verification_uri"`
|
||||
VerificationUriComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// routeDevice registers POST /v1/iam/oauth/device on the PUBLIC group r
|
||||
// (registered before the Guard, exactly like the token endpoint): the endpoint
|
||||
// authenticates the CLIENT inline — a confidential client by its secret, a
|
||||
// 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 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.Post(PathDeviceInfo, deviceInfoHandler(db))
|
||||
}
|
||||
|
||||
// deviceInfo is what the approval page must show a human: WHICH application is
|
||||
// asking to sign in. Both fields come off the pending device code's own
|
||||
// application — never off the portal the browser happens to be on.
|
||||
type deviceInfo struct {
|
||||
ClientId string `json:"clientId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// deviceInfoHandler answers "what am I approving?" for a pending device code.
|
||||
//
|
||||
// The approval page exists to tell a human WHICH application they are authorizing;
|
||||
// a page that names the wrong one defeats the control it implements. It used to
|
||||
// render the portal's own app name — a constant, `hanzo-console` for every code —
|
||||
// so a device code minted by `hanzo-cli` was approved under a screen naming a
|
||||
// different application entirely. The client is a property of the CODE, so it is
|
||||
// read from the code's row here and nowhere else.
|
||||
//
|
||||
// Requires a signed-in session, and answers with the same ONE opaque refusal
|
||||
// approveDevice uses. That is deliberate: the user_code is only 40 bits and is the
|
||||
// one secret in this flow, so an unauthenticated lookup — or one that
|
||||
// distinguished unknown from expired from already-approved — would be an oracle
|
||||
// for hunting live codes. Gated and opaque, it reveals strictly less than the
|
||||
// approval the same caller could already attempt.
|
||||
func deviceInfoHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
|
||||
// The identity is the browser's session, exactly as the approval itself
|
||||
// resolves it. Not signed in is not a refusal to explain — it is the
|
||||
// page's cue to sign the human in first, so it carries the stable code
|
||||
// the SPA branches on.
|
||||
owner, name, ok := sessions.Resolve(ctx, c.Fiber(), db)
|
||||
if !ok {
|
||||
return httpx.ErrCode(c, "please sign in first", CodeLoginRequired)
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil || user == nil || user.IsForbidden || user.IsDeleted {
|
||||
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, userCode)
|
||||
if err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
|
||||
expired(row.CodeExpireIn, nowFunc()) {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
// The same tenant boundary approveDevice enforces: what you may LOOK AT is
|
||||
// exactly what you may approve, so this leaks nothing the caller could not
|
||||
// already have learned by approving.
|
||||
if row.Organization == "" {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
|
||||
return httpx.Err(c, "your organization may not approve this device sign-in")
|
||||
}
|
||||
|
||||
app, err := resolveTokenApp(ctx, db, row)
|
||||
if err != nil || app == nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
label := app.DisplayName
|
||||
if label == "" {
|
||||
label = app.Name
|
||||
}
|
||||
return httpx.Ok(c, deviceInfo{ClientId: app.ClientId, DisplayName: label})
|
||||
}
|
||||
}
|
||||
|
||||
// deviceHandler starts a sign-in on a device with no browser and no keyboard —
|
||||
// a TV, a CLI, a headless box. It returns a short code to show the person and
|
||||
// the address to send them to on a phone or laptop.
|
||||
//
|
||||
// Nothing is granted until a human approves it there; until then the code is
|
||||
// just a pending request.
|
||||
func deviceHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_client", "client_id is invalid")
|
||||
}
|
||||
// A confidential client (one with a registered secret) MUST authenticate
|
||||
// (RFC 8628 §3.1 → RFC 6749 §3.2.1). A public device client has no secret
|
||||
// and is identified by its client_id alone.
|
||||
if app.ClientSecret != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if !appGrants(app, deviceGrant) {
|
||||
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
|
||||
}
|
||||
|
||||
deviceCode, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
userCode, err := newUserCode(ctx, db)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
|
||||
// One row IS the pending authorization: Code is the device_code the
|
||||
// machine polls with, UserCode the code its human transcribes, and an
|
||||
// empty User means nobody has approved yet.
|
||||
row := &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Application: app.Name,
|
||||
Organization: app.Organization,
|
||||
Code: deviceCode,
|
||||
UserCode: userCode,
|
||||
Scope: param(c, "scope"),
|
||||
TokenType: "Bearer",
|
||||
CodeExpireIn: nowFunc().Add(deviceCodeTTL).Unix(),
|
||||
}
|
||||
row.Name = "dc-" + deviceCode[:24]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
|
||||
// Both URIs point at the SPA approval page a human opens, never at this
|
||||
// JSON API. The complete form is a PATH segment because that is the route
|
||||
// the page is registered on (/login/oauth/device/:userCode).
|
||||
verify := tokenIssuer(c) + PathDeviceVerify
|
||||
return c.JSON(200, deviceResponse{
|
||||
DeviceCode: deviceCode,
|
||||
UserCode: userCode,
|
||||
VerificationUri: verify,
|
||||
VerificationUriComplete: verify + "/" + userCode,
|
||||
ExpiresIn: int(deviceCodeTTL.Seconds()),
|
||||
Interval: devicePollInterval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deviceCodeGrant is the device's poll (RFC 8628 §3.4), dispatched from the one
|
||||
// token endpoint. It authenticates the client (confidential by secret, public by
|
||||
// client_id) and answers `authorization_pending` until a human approves, then
|
||||
// mints exactly once. The human who authenticated and approved at the
|
||||
// verification URI IS the end-user authentication.
|
||||
func deviceCodeGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
presented := param(c, "device_code")
|
||||
if presented == "" {
|
||||
return tokenError(c, 400, "invalid_request", "device_code is required")
|
||||
}
|
||||
row, err := store.GetTokenByCode(ctx, db, presented)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
// Unknown, not a device authorization, or already redeemed. isDevice is what
|
||||
// stops an authorization code being redeemed HERE, where neither its PKCE
|
||||
// challenge nor its redirect_uri is verified.
|
||||
if row == nil || !isDevice(row) || row.CodeIsUsed {
|
||||
return deviceDead(c)
|
||||
}
|
||||
// Expired — reap it on the way past, so a dead authorization does not linger.
|
||||
if expired(row.CodeExpireIn, now) {
|
||||
_ = store.DeleteToken(ctx, db, row)
|
||||
return deviceDead(c)
|
||||
}
|
||||
|
||||
app, err := resolveTokenApp(ctx, db, row)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "the device code is invalid")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if deviceClientMismatch(app, clientID) {
|
||||
return tokenError(c, 400, "invalid_grant", "the device_code was not issued to this client")
|
||||
}
|
||||
// A confidential client authenticates on EVERY poll (RFC 8628 §3.4 → RFC 6749
|
||||
// §3.2.1), checked before the pending/mint split so an unauthenticated
|
||||
// confidential poll never even learns the grant's approval state. A public
|
||||
// device client has no secret and is bound by its client_id alone (above).
|
||||
if app.ClientSecret != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
// Re-gated at redemption, not only at the request: an application whose device
|
||||
// grant was withdrawn between the two must not still mint.
|
||||
if !appGrants(app, deviceGrant) {
|
||||
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
|
||||
}
|
||||
// Not approved yet: leave the row exactly as it is — the device keeps polling.
|
||||
if row.User == "" {
|
||||
return tokenError(c, 400, "authorization_pending", "the device authorization is pending approval")
|
||||
}
|
||||
|
||||
// One-shot: burn the approval BEFORE minting, so any later poll finds the row
|
||||
// already redeemed rather than minting a second token off one approval. Like
|
||||
// the authorization-code grant beside it this is a read-modify-write, not a
|
||||
// compare-and-swap: two polls landing inside the same write window could still
|
||||
// both mint. They mint the same user, app, scope and refresh family, so the
|
||||
// duplicate is contained (revoking the family revokes both) — a real CAS is a
|
||||
// property the Token row would have to carry for every grant, not just this one.
|
||||
row.CodeIsUsed = true
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// approveDevice binds an authenticated human's identity onto a pending device
|
||||
// authorization — the act that lets the device's next poll mint. The row's
|
||||
// application and scope stay authoritative for that mint: the portal app the
|
||||
// browser happens to be on is irrelevant to WHAT is being approved, so it is
|
||||
// never read here. Called from the login handler once the credential check has
|
||||
// already proven who the approver is.
|
||||
func approveDevice(c *zip.Ctx, db orm.DB, user *schema.User, userCode string) error {
|
||||
// ONE opaque refusal for unknown / not-a-device / expired / already-approved /
|
||||
// already-redeemed. The user_code is only 40 bits — the one secret in this
|
||||
// flow — so an answer that distinguished those cases would turn this page into
|
||||
// an oracle for hunting live codes.
|
||||
const refuse = "the user code is invalid or expired"
|
||||
|
||||
ctx := c.Context()
|
||||
row, err := store.GetTokenByUserCode(ctx, db, userCode)
|
||||
if err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
|
||||
expired(row.CodeExpireIn, nowFunc()) {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
// Tenant boundary: a user in org A must not approve a device sign-in bound to
|
||||
// an app in org B (a confused deputy — brands seed same-named superusers). The
|
||||
// org compared is the DEVICE row's, captured when the code was issued. A
|
||||
// SuperAdmin — a member of the reserved admin org, the one predicate — crosses
|
||||
// tenants deliberately: that is the identity an operator signs a CLI into any
|
||||
// brand's app with. An unresolvable tenant fails closed.
|
||||
if row.Organization == "" {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
|
||||
return httpx.Err(c, "your organization may not approve this device sign-in")
|
||||
}
|
||||
|
||||
row.User = user.Owner + "/" + user.Name
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
return httpx.Ok(c, row.User)
|
||||
}
|
||||
|
||||
// deviceDead is the one answer for a device_code that cannot be redeemed —
|
||||
// unknown, not a device authorization, already redeemed, or expired. To the
|
||||
// client those are the same fact (this code is dead, start over), so they get
|
||||
// the same words: sharing one answer makes that structural rather than a
|
||||
// coincidence of copied strings.
|
||||
func deviceDead(c *zip.Ctx) error {
|
||||
return tokenError(c, 400, "expired_token", "the device code is expired or already redeemed")
|
||||
}
|
||||
|
||||
// isDevice reports whether a code row is an RFC 8628 device authorization rather
|
||||
// than an authorization code. Both kinds live in Token.Code, so every grant
|
||||
// checks the kind before redeeming: an authorization code must never be redeemed
|
||||
// at the device grant, which verifies neither PKCE nor redirect_uri, and a
|
||||
// device code must never be redeemed at the authorization-code grant, which
|
||||
// would mint on a row no human has approved. The user_code IS the
|
||||
// discriminator — only a device authorization has one.
|
||||
func isDevice(tok *schema.Token) bool { return tok != nil && tok.UserCode != "" }
|
||||
|
||||
// deviceClientMismatch reports whether clientID is NOT the client the device
|
||||
// authorization was issued to (RFC 8628 §3.4). Without this an approval for app
|
||||
// A is redeemable as app B: a confused deputy that hands the caller a token for
|
||||
// the wrong audience. Pure, so the binding is unit-testable.
|
||||
func deviceClientMismatch(app *schema.Application, clientID string) bool {
|
||||
return app == nil ||
|
||||
subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1
|
||||
}
|
||||
|
||||
// appGrants reports whether app permits grant — the per-application grant gate
|
||||
// (v1 IsGrantTypeValid, object/token_oauth.go:605). A grant must be DECLARED on
|
||||
// the application to be usable, so an app that never enabled the device grant can
|
||||
// never mint a device token. Fail-closed by construction: every live application
|
||||
// declares its grant set, so an app with none permits none.
|
||||
func appGrants(app *schema.Application, grant string) bool {
|
||||
if app == nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range app.GrantTypes {
|
||||
if g == grant {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expired reports whether a unix deadline has passed. A zero deadline never
|
||||
// expires (the v1 convention for "unset").
|
||||
func expired(deadline int64, now time.Time) bool {
|
||||
return deadline != 0 && now.Unix() > deadline
|
||||
}
|
||||
|
||||
// newUserCode mints a user_code that no live row already carries. Each attempt
|
||||
// REGENERATES the candidate — a loop that re-tests one fixed code could never
|
||||
// clear a collision.
|
||||
func newUserCode(ctx context.Context, db orm.DB) (string, error) {
|
||||
for range userCodeTries {
|
||||
code, err := randomUserCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
row, err := store.GetTokenByUserCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row == nil {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", errUserCodeExhausted
|
||||
}
|
||||
|
||||
// randomUserCode draws userCodeLen symbols uniformly from userCodeAlphabet.
|
||||
func randomUserCode() (string, error) {
|
||||
buf := make([]byte, userCodeLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = userCodeAlphabet[buf[i]&0x1f]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The approval page exists to tell a human WHICH application they are authorizing.
|
||||
// It used to render the PORTAL's own app name — a per-brand constant — so a device
|
||||
// code minted by `hanzo-cli` was approved on a screen naming a different
|
||||
// application entirely. A security control that displays false information is
|
||||
// worse than no control, because it manufactures the confidence it should be
|
||||
// earning.
|
||||
//
|
||||
// These tests pin the property that fixes it: the name comes off the CODE.
|
||||
|
||||
// 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 := jsonReq("POST", PathDeviceInfo, map[string]string{"userCode": userCode})
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
_, body := do(t, app, req)
|
||||
return decode(t, body)
|
||||
}
|
||||
|
||||
// mintDeviceCode starts a device authorization and returns its user_code.
|
||||
func mintDeviceCode(t *testing.T, app *zip.App, clientID string) string {
|
||||
t.Helper()
|
||||
resp, out := requestDevice(t, app, clientID, "openid")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("device request status=%d body=%v", resp.StatusCode, out)
|
||||
}
|
||||
code, _ := out["user_code"].(string)
|
||||
if code == "" {
|
||||
t.Fatalf("no user_code minted: %v", out)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// The whole defect, in one assertion: two applications exist, the code is minted
|
||||
// by ONE of them, and the page must be told about that one — never the portal the
|
||||
// browser happens to be sitting on.
|
||||
func TestDeviceInfo_NamesTheCodesClient(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// The portal the browser is on. If the answer were read from here — as the
|
||||
// page used to do — this is the name that would come back.
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
// The client that actually asks to sign in on the device.
|
||||
seedDeviceApp(t, db, "hanzo-cli")
|
||||
|
||||
userCode := mintDeviceCode(t, app, "hanzo-cli")
|
||||
env := deviceInfoGet(t, app, userCode, signIn(t, app, "hanzo-console"))
|
||||
if env["status"] != "ok" {
|
||||
t.Fatalf("device info failed: %v", env)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["clientId"] != "hanzo-cli" {
|
||||
t.Fatalf("device info named %q — it must name the client that minted the code, not the portal", data["clientId"])
|
||||
}
|
||||
if data["clientId"] == "hanzo-console" {
|
||||
t.Fatal("device info returned the PORTAL's client — the exact defect this endpoint exists to fix")
|
||||
}
|
||||
if s, _ := data["displayName"].(string); s == "" {
|
||||
t.Fatal("device info must carry a human-readable name to display")
|
||||
}
|
||||
}
|
||||
|
||||
// The user_code is 40 bits and is the one secret in this flow. An unauthenticated
|
||||
// lookup would be an oracle for hunting live codes, so the endpoint requires a
|
||||
// session — and says so in a way the page can route on, rather than demanding
|
||||
// credentials the page does not collect.
|
||||
func TestDeviceInfo_RequiresSession(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-cli")
|
||||
userCode := mintDeviceCode(t, app, "hanzo-cli")
|
||||
|
||||
env := deviceInfoGet(t, app, userCode, "")
|
||||
if env["status"] != "error" {
|
||||
t.Fatalf("an anonymous lookup must be refused: %v", env)
|
||||
}
|
||||
if env["code"] != CodeLoginRequired {
|
||||
t.Fatalf("code = %v, want %q so the page can show a sign-in form", env["code"], CodeLoginRequired)
|
||||
}
|
||||
if data, ok := env["data"].(map[string]any); ok && data["clientId"] != nil {
|
||||
t.Fatal("an anonymous refusal leaked the client")
|
||||
}
|
||||
}
|
||||
|
||||
// ONE opaque refusal for unknown / expired / already-approved. An answer that
|
||||
// distinguished them would turn the page into a code-hunting oracle.
|
||||
func TestDeviceInfo_OpaqueRefusal(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedDeviceApp(t, db, "hanzo-cli")
|
||||
cookie := signIn(t, app, "hanzo-console")
|
||||
|
||||
live := mintDeviceCode(t, app, "hanzo-cli")
|
||||
unknown := deviceInfoGet(t, app, "ZZZZZZZZ", cookie)
|
||||
if unknown["status"] != "error" {
|
||||
t.Fatalf("an unknown code must be refused: %v", unknown)
|
||||
}
|
||||
|
||||
// Approve the live code, then look it up again: an already-approved code must
|
||||
// read exactly like an unknown one.
|
||||
approveFor(t, app, live, cookie)
|
||||
approved := deviceInfoGet(t, app, live, cookie)
|
||||
if approved["status"] != "error" {
|
||||
t.Fatalf("an already-approved code must be refused: %v", approved)
|
||||
}
|
||||
if approved["msg"] != unknown["msg"] {
|
||||
t.Fatalf("refusals differ (%q vs %q) — that difference is an oracle", approved["msg"], unknown["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
// What you may LOOK AT is exactly what you may approve: a user in another org
|
||||
// learns nothing about a code bound to an app they could never authorize.
|
||||
func TestDeviceInfo_TenantBoundary(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "s3cret", redirectURIs: []string{testRedirect}, shared: true})
|
||||
seedDeviceApp(t, db, "hanzo-cli")
|
||||
seedUserInOrg(t, db, "other", "alice", "alice@other.example", "pw")
|
||||
|
||||
userCode := mintDeviceCode(t, app, "hanzo-cli")
|
||||
|
||||
// Sign in as the OTHER org's alice.
|
||||
form := url.Values{
|
||||
"organization": {"other"}, "application": {"hanzo-console"},
|
||||
"username": {"alice"}, "password": {"pw"}, "type": {"login"},
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathLogin, form))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Skipf("cross-org sign-in unavailable in this harness: %s", body)
|
||||
}
|
||||
env := deviceInfoGet(t, app, userCode, cookieKV(resp.Header.Get("Set-Cookie")))
|
||||
if env["status"] != "error" {
|
||||
t.Fatalf("a user in another org must not learn the client: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// approveFor approves a pending user_code as the signed-in browser.
|
||||
func approveFor(t *testing.T, app *zip.App, userCode, cookie string) {
|
||||
t.Helper()
|
||||
req := jsonReq("POST", PathLogin, map[string]string{"type": "device", "userCode": userCode})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
_, body := do(t, app, req)
|
||||
if decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Defect 2: a device approval posted with NO session used to fall through to the
|
||||
// credential check and answer "organization, username and password are required"
|
||||
// — naming three fields the approval page has never rendered and never sends. The
|
||||
// device flow exists precisely because you are approving from a DIFFERENT device,
|
||||
// so a fresh browser with no session is the ordinary case. Guaranteed dead end.
|
||||
func TestLogin_DeviceWithoutSessionSaysSignIn(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-cli")
|
||||
userCode := mintDeviceCode(t, app, "hanzo-cli")
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"type": "device", "userCode": userCode,
|
||||
}))
|
||||
env := decode(t, body)
|
||||
if env["status"] != "error" {
|
||||
t.Fatalf("expected a refusal, got %s", body)
|
||||
}
|
||||
msg, _ := env["msg"].(string)
|
||||
if msg == "organization, username and password are required" {
|
||||
t.Fatal("the device page renders no organization/username/password fields — demanding them is a dead end")
|
||||
}
|
||||
if env["code"] != CodeLoginRequired {
|
||||
t.Fatalf("code = %v, want %q so the page can redirect to sign-in and return with the user_code", env["code"], CodeLoginRequired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// The RFC 8628 device grant, driven through the real router exactly as the two
|
||||
// live CLIs drive it: client_id/scope as QUERY params on the device request
|
||||
// (cloud/cli/device.go, codex-rs oidc_device_auth.rs), then a form-encoded poll
|
||||
// at the one token endpoint.
|
||||
|
||||
// deviceGrants is the grant set a device-capable app declares — what hanzo-app
|
||||
// carries in the live seed.
|
||||
var deviceGrants = []string{"authorization_code", "refresh_token", deviceGrant}
|
||||
|
||||
// seedDeviceApp seeds a public, device-capable app plus a user in its org.
|
||||
func seedDeviceApp(t *testing.T, db orm.DB, clientID string) {
|
||||
t.Helper()
|
||||
seedApp(t, db, appOpts{clientID: clientID, grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
}
|
||||
|
||||
// requestDevice drives POST /v1/iam/oauth/device the way a PUBLIC device client
|
||||
// does: client_id/scope only, no secret.
|
||||
func requestDevice(t *testing.T, app *zip.App, clientID, scope string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return requestDeviceSecret(t, app, clientID, "", scope)
|
||||
}
|
||||
|
||||
// requestDeviceSecret drives the device request with an optional client_secret —
|
||||
// the confidential-client leg (RFC 8628 §3.1). An empty secret is the public
|
||||
// case.
|
||||
func requestDeviceSecret(t *testing.T, app *zip.App, clientID, secret, scope string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
q := url.Values{"client_id": {clientID}, "scope": {scope}, "response_type": {"device_code"}}
|
||||
if secret != "" {
|
||||
q.Set("client_secret", secret)
|
||||
}
|
||||
resp, body := do(t, app, formReqNoBody("POST", PathDevice+"?"+q.Encode()))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
// pollDevice drives one device poll at the token endpoint (public client).
|
||||
func pollDevice(t *testing.T, app *zip.App, clientID, deviceCode string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return pollDeviceSecret(t, app, clientID, "", deviceCode)
|
||||
}
|
||||
|
||||
// pollDeviceSecret drives one device poll with an optional client_secret — the
|
||||
// confidential-client leg (RFC 8628 §3.4).
|
||||
func pollDeviceSecret(t *testing.T, app *zip.App, clientID, secret, deviceCode string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"grant_type": {deviceGrant},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceCode},
|
||||
}
|
||||
if secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
// approveAs drives the human approval leg: POST /v1/iam/login {type:"device"}.
|
||||
func approveAs(t *testing.T, app *zip.App, org, user, userCode string) map[string]any {
|
||||
t.Helper()
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": org, "username": user, "password": "pw",
|
||||
"type": "device", "userCode": userCode,
|
||||
}))
|
||||
return decode(t, body)
|
||||
}
|
||||
|
||||
// The device response carries exactly the keys both CLIs decode, with the TTL
|
||||
// and poll interval the server actually enforces. cloud/cli/device.go hard-fails
|
||||
// on an empty device_code/user_code, so an error envelope here is a dead CLI.
|
||||
func TestDevice_RequestShape(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
|
||||
resp, m := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d: %v", resp.StatusCode, m)
|
||||
}
|
||||
deviceCode, _ := m["device_code"].(string)
|
||||
userCode, _ := m["user_code"].(string)
|
||||
if deviceCode == "" || userCode == "" {
|
||||
t.Fatalf("device_code/user_code must be non-empty: %v", m)
|
||||
}
|
||||
if m["expires_in"] != float64(900) {
|
||||
t.Errorf("expires_in = %v, want 900", m["expires_in"])
|
||||
}
|
||||
if m["interval"] != float64(5) {
|
||||
t.Errorf("interval = %v, want 5", m["interval"])
|
||||
}
|
||||
// verification_uri_complete must be the PATH form: the SPA route is
|
||||
// /login/oauth/device/:userCode.
|
||||
verify, _ := m["verification_uri"].(string)
|
||||
if verify != "https://hanzo.id"+PathDeviceVerify {
|
||||
t.Errorf("verification_uri = %q", verify)
|
||||
}
|
||||
if got, want := m["verification_uri_complete"], verify+"/"+userCode; got != want {
|
||||
t.Errorf("verification_uri_complete = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
// The user_code must be transcribable AND survive the portal's
|
||||
// normalization (uppercase, separators stripped) unchanged — a code the
|
||||
// portal rewrites is a code the lookup can never find.
|
||||
if len(userCode) != userCodeLen {
|
||||
t.Errorf("user_code %q: length %d, want %d", userCode, len(userCode), userCodeLen)
|
||||
}
|
||||
if got := strings.ToUpper(strings.ReplaceAll(userCode, "-", "")); got != userCode {
|
||||
t.Errorf("user_code %q is not already normalized (portal would send %q)", userCode, got)
|
||||
}
|
||||
for _, r := range userCode {
|
||||
if !strings.ContainsRune(userCodeAlphabet, r) {
|
||||
t.Errorf("user_code %q contains ambiguous symbol %q", userCode, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The pending grant is a persisted row, not process-local state.
|
||||
row, err := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("device authorization was not persisted: %v", err)
|
||||
}
|
||||
if row.User != "" {
|
||||
t.Errorf("a fresh device authorization must be unapproved, got user %q", row.User)
|
||||
}
|
||||
if row.UserCode != userCode {
|
||||
t.Errorf("row.UserCode = %q, want %q", row.UserCode, userCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery advertises the device endpoint and grant so a discovery-driven
|
||||
// client can find them.
|
||||
func TestDevice_Discovery(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
_, body := do(t, app, formReqNoBody("GET", PathDiscovery))
|
||||
d := decode(t, body)
|
||||
if d["device_authorization_endpoint"] != "https://hanzo.id"+PathDevice {
|
||||
t.Errorf("device_authorization_endpoint = %v, want %v", d["device_authorization_endpoint"], "https://hanzo.id"+PathDevice)
|
||||
}
|
||||
gts, _ := d["grant_types_supported"].([]any)
|
||||
found := false
|
||||
for _, g := range gts {
|
||||
if g == deviceGrant {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("grant_types_supported missing %q: %v", deviceGrant, gts)
|
||||
}
|
||||
}
|
||||
|
||||
// Before approval the poll answers authorization_pending and LEAVES the row —
|
||||
// the CLI polls on this answer, so consuming the row would end the login.
|
||||
func TestDevice_PollPendingIsRepeatable(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode := da["device_code"].(string)
|
||||
|
||||
for i := range 3 {
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("poll %d: status %d, want 400", i, resp.StatusCode)
|
||||
}
|
||||
if m["error"] != "authorization_pending" {
|
||||
t.Fatalf("poll %d: error = %v, want authorization_pending", i, m["error"])
|
||||
}
|
||||
// A 401 would send the CLI down its terminal error path.
|
||||
if resp.Header.Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("poll %d: a pending poll must not carry a WWW-Authenticate challenge", i)
|
||||
}
|
||||
}
|
||||
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row == nil {
|
||||
t.Fatal("a pending poll must not consume the device authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point, end to end: approve once, mint once. The SECOND poll of an
|
||||
// approved code must fail — one approval is one token.
|
||||
func TestDevice_ApproveThenPollMintsExactlyOnce(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
// The approval binds the approver onto the row — identity comes from there,
|
||||
// never from the polling device.
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if row == nil || row.User != "hanzo/alice" {
|
||||
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("approved poll: status %d: %v", resp.StatusCode, m)
|
||||
}
|
||||
access, _ := m["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("approved poll must mint an access_token: %v", m)
|
||||
}
|
||||
if id, _ := m["id_token"].(string); id == "" {
|
||||
t.Error("the openid scope must mint an id_token")
|
||||
}
|
||||
if rt, _ := m["refresh_token"].(string); rt == "" {
|
||||
t.Error("the device grant must mint a refresh token")
|
||||
}
|
||||
// The minted token describes the approver, and is usable.
|
||||
claims, err := verifyToken(tctx(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("minted access token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" {
|
||||
t.Errorf("sub = %q, want hanzo/alice", claims.Subject)
|
||||
}
|
||||
|
||||
// One approval, one token: a replayed poll gets nothing.
|
||||
resp2, m2 := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp2.StatusCode != 400 || m2["error"] != "expired_token" {
|
||||
t.Fatalf("second poll: %d %v, want 400 expired_token", resp2.StatusCode, m2)
|
||||
}
|
||||
if _, ok := m2["access_token"]; ok {
|
||||
t.Fatal("a redeemed device code must never mint twice")
|
||||
}
|
||||
}
|
||||
|
||||
// A CONFIDENTIAL device client authenticates at BOTH legs (RFC 8628 §3.1 request,
|
||||
// §3.4 poll). Without its secret the request is refused and the poll — even of an
|
||||
// approved code — mints nothing; with the secret both succeed.
|
||||
func TestDevice_ConfidentialClientAuth(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf-cli", secret: "s3cret", grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
// §3.1: a confidential client's device request without its secret is refused.
|
||||
resp, m := requestDevice(t, app, "conf-cli", "openid")
|
||||
if resp.StatusCode != 401 || m["error"] != "invalid_client" {
|
||||
t.Fatalf("unauthenticated device request: %d %v, want 401 invalid_client", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("a refused device request must not mint a device_code")
|
||||
}
|
||||
|
||||
// With the secret it succeeds.
|
||||
resp, m = requestDeviceSecret(t, app, "conf-cli", "s3cret", "openid")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("authenticated device request: %d %v", resp.StatusCode, m)
|
||||
}
|
||||
deviceCode, userCode := m["device_code"].(string), m["user_code"].(string)
|
||||
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", am)
|
||||
}
|
||||
|
||||
// §3.4: the poll without the secret is refused — even though the code is
|
||||
// approved — and mints nothing.
|
||||
presp, pm := pollDevice(t, app, "conf-cli", deviceCode)
|
||||
if presp.StatusCode != 401 || pm["error"] != "invalid_client" {
|
||||
t.Fatalf("unauthenticated poll: %d %v, want 401 invalid_client", presp.StatusCode, pm)
|
||||
}
|
||||
if _, ok := pm["access_token"]; ok {
|
||||
t.Fatal("an unauthenticated confidential poll must never mint")
|
||||
}
|
||||
|
||||
// With the secret the poll mints.
|
||||
presp, pm = pollDeviceSecret(t, app, "conf-cli", "s3cret", deviceCode)
|
||||
if presp.StatusCode != 200 || pm["access_token"] == nil {
|
||||
t.Fatalf("authenticated poll must mint: %d %v", presp.StatusCode, pm)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant boundary: a user in org B must not approve a device sign-in bound to an
|
||||
// app in org A. A SuperAdmin — a member of the reserved admin org — may, because
|
||||
// that is the identity an operator signs a CLI into any brand with.
|
||||
func TestDevice_ApprovalTenantBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
org string // approver's org; the device app lives in "hanzo"
|
||||
allow bool
|
||||
}{
|
||||
{"same org approves", "hanzo", true},
|
||||
{"foreign org refused", "lux", false},
|
||||
{"superadmin crosses tenants", "admin", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants}) // org "hanzo"
|
||||
seedUserInOrg(t, db, tc.org, "eve", "eve@"+tc.org+".example", "pw")
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
m := approveAs(t, app, tc.org, "eve", userCode)
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
|
||||
if !tc.allow {
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("cross-tenant approval must be refused, got %v", m)
|
||||
}
|
||||
// The store is the proof: refused means NOT approved.
|
||||
if row.User != "" {
|
||||
t.Fatalf("refused approval must not bind a user, got %q", row.User)
|
||||
}
|
||||
// And the device must still not be able to mint.
|
||||
if _, p := pollDevice(t, app, "hanzo-app", deviceCode); p["error"] != "authorization_pending" {
|
||||
t.Fatalf("a refused approval must leave the device pending, got %v", p)
|
||||
}
|
||||
return
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("approval must succeed, got %v", m)
|
||||
}
|
||||
if row.User != tc.org+"/eve" {
|
||||
t.Fatalf("row.User = %q, want %q", row.User, tc.org+"/eve")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8628 §3.4: a device_code is redeemable only by the client it was issued
|
||||
// to. Otherwise an approval for app A is redeemable as app B — a token for the
|
||||
// wrong audience.
|
||||
func TestDevice_ClientBinding(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
seedApp(t, db, appOpts{clientID: "other-app", grants: deviceGrants})
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "other-app", deviceCode)
|
||||
if resp.StatusCode != 400 || m["error"] != "invalid_grant" {
|
||||
t.Fatalf("foreign client redemption: %d %v, want 400 invalid_grant", resp.StatusCode, m)
|
||||
}
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("a device_code must never be redeemable by another client")
|
||||
}
|
||||
// The rightful client can still redeem — the binding refused, it did not burn.
|
||||
if _, own := pollDevice(t, app, "hanzo-app", deviceCode); own["access_token"] == nil {
|
||||
t.Fatalf("the issuing client must still redeem its own code: %v", own)
|
||||
}
|
||||
}
|
||||
|
||||
// An expired device_code is dead even once approved.
|
||||
func TestDevice_Expiry(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
start := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, start)
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
|
||||
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 400 || m["error"] != "expired_token" {
|
||||
t.Fatalf("expired poll: %d %v, want 400 expired_token", resp.StatusCode, m)
|
||||
}
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("an expired device code must never mint")
|
||||
}
|
||||
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row != nil {
|
||||
t.Error("an expired device authorization should be reaped on the poll that finds it")
|
||||
}
|
||||
}
|
||||
|
||||
// The per-application grant gate: an app that never declared the device grant
|
||||
// can neither start a device flow nor redeem one. Gated at BOTH ends, so a row
|
||||
// created while the grant was enabled cannot mint after it is withdrawn.
|
||||
func TestDevice_GrantGate(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// A real app, fully functional — it simply never declared the device grant.
|
||||
seedApp(t, db, appOpts{clientID: "web-only", grants: []string{"authorization_code", "refresh_token"}})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, m := requestDevice(t, app, "web-only", "openid")
|
||||
if resp.StatusCode != 400 || m["error"] != "unsupported_grant_type" {
|
||||
t.Fatalf("request: %d %v, want 400 unsupported_grant_type", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("a refused device request must not mint a device_code")
|
||||
}
|
||||
|
||||
// And at the poll: forge a device row for the app, as if the grant had been
|
||||
// enabled and then withdrawn, and prove the redemption is still refused.
|
||||
seedApp(t, db, appOpts{clientID: "was-enabled", grants: deviceGrants})
|
||||
_, da := requestDevice(t, app, "was-enabled", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", am)
|
||||
}
|
||||
withdrawGrants(t, db, "was-enabled")
|
||||
|
||||
resp2, m2 := pollDevice(t, app, "was-enabled", deviceCode)
|
||||
if resp2.StatusCode != 400 || m2["error"] != "unsupported_grant_type" {
|
||||
t.Fatalf("poll after withdrawal: %d %v, want 400 unsupported_grant_type", resp2.StatusCode, m2)
|
||||
}
|
||||
if _, ok := m2["access_token"]; ok {
|
||||
t.Fatal("an app without the device grant must never mint a device token")
|
||||
}
|
||||
}
|
||||
|
||||
// The user_code is the only secret in the approval flow (40 bits), so unknown,
|
||||
// expired, and already-approved codes must be indistinguishable — otherwise the
|
||||
// approval page is an oracle for hunting live codes.
|
||||
func TestDevice_UserCodeRefusalIsNonDifferential(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
start := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, start)
|
||||
|
||||
// (a) unknown
|
||||
unknown := approveAs(t, app, "hanzo", "alice", "ZZZZZZZZ")
|
||||
|
||||
// (b) already approved
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
if m := approveAs(t, app, "hanzo", "alice", da["user_code"].(string)); m["status"] != "ok" {
|
||||
t.Fatalf("first approval must succeed: %v", m)
|
||||
}
|
||||
reapproved := approveAs(t, app, "hanzo", "alice", da["user_code"].(string))
|
||||
|
||||
// (c) expired
|
||||
_, da2 := requestDevice(t, app, "hanzo-app", "openid")
|
||||
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
|
||||
expiredCode := approveAs(t, app, "hanzo", "alice", da2["user_code"].(string))
|
||||
|
||||
for _, m := range []map[string]any{unknown, reapproved, expiredCode} {
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("must be refused: %v", m)
|
||||
}
|
||||
}
|
||||
if unknown["msg"] != reapproved["msg"] || unknown["msg"] != expiredCode["msg"] {
|
||||
t.Fatalf("refusals differ — an oracle: unknown=%q reapproved=%q expired=%q",
|
||||
unknown["msg"], reapproved["msg"], expiredCode["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
// An AUTHORIZATION code must never be redeemable at the device grant. The device
|
||||
// grant verifies neither PKCE nor redirect_uri, so accepting one there would
|
||||
// defeat both for any app that permits the device grant — a stolen code would
|
||||
// mint tokens with no verifier.
|
||||
func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants, redirectURIs: []string{testRedirect}})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
verifier := "device-xchg-verifier-00000000000000000000000000000"
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("setup: no authorization code minted")
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "hanzo-app", code)
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("PKCE BYPASS: an authorization code was redeemed at the device grant")
|
||||
}
|
||||
if resp.StatusCode != 400 || m["error"] != "expired_token" {
|
||||
t.Fatalf("got %d %v, want 400 expired_token", resp.StatusCode, m)
|
||||
}
|
||||
// The real exchange still works — the guard refused, it did not burn the code.
|
||||
if _, tm := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"hanzo-app"},
|
||||
"code_verifier": {verifier}, "redirect_uri": {testRedirect},
|
||||
}); tm["access_token"] == nil {
|
||||
t.Fatalf("the legitimate code exchange must still succeed: %v", tm)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror image: a DEVICE code must never be redeemable at the
|
||||
// authorization-code grant, which would mint on a row no human has approved.
|
||||
func TestDevice_DeviceCodeIsNotRedeemableAsAuthorizationCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// A CONFIDENTIAL app: its secret would otherwise satisfy the code grant's
|
||||
// client check, and an unapproved device row carries no PKCE challenge to
|
||||
// stop it. The device request authenticates that same secret (§3.1).
|
||||
seedApp(t, db, appOpts{clientID: "conf-app", secret: "s3cret", grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
_, da := requestDeviceSecret(t, app, "conf-app", "s3cret", "openid")
|
||||
deviceCode := da["device_code"].(string)
|
||||
|
||||
_, m := exchangeCode(t, app, url.Values{
|
||||
"code": {deviceCode}, "client_id": {"conf-app"}, "client_secret": {"s3cret"},
|
||||
})
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("APPROVAL BYPASS: an unapproved device code minted at the authorization-code grant")
|
||||
}
|
||||
if m["error"] != "invalid_grant" {
|
||||
t.Fatalf("error = %v, want invalid_grant", m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown client_id is refused — and mints nothing.
|
||||
func TestDevice_UnknownClient(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
resp, m := requestDevice(t, app, "no-such-client", "openid")
|
||||
if resp.StatusCode != 400 || m["error"] != "invalid_client" {
|
||||
t.Fatalf("got %d %v, want 400 invalid_client", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("an unknown client must not mint a device_code")
|
||||
}
|
||||
}
|
||||
|
||||
// appGrants is the pure gate both ends call.
|
||||
func TestAppGrants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declare []string
|
||||
want bool
|
||||
}{
|
||||
{"declared", deviceGrants, true},
|
||||
{"not declared", []string{"authorization_code", "refresh_token"}, false},
|
||||
{"none declared", nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := appGrants(&schema.Application{GrantTypes: tc.declare}, deviceGrant); got != tc.want {
|
||||
t.Fatalf("appGrants(%v) = %v, want %v", tc.declare, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if appGrants(nil, deviceGrant) {
|
||||
t.Fatal("a nil application must permit nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// user_codes are drawn fresh each time — a generator that reuses one value could
|
||||
// never clear a collision.
|
||||
func TestRandomUserCode_Distinct(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 64 {
|
||||
code, err := randomUserCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("user_code %q repeated — the draw is not random", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawGrants strips an application's declared grants in place.
|
||||
func withdrawGrants(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
a, err := orm.Get[schema.Application](db, "admin/"+name)
|
||||
if err != nil {
|
||||
t.Fatalf("load app %s: %v", name, err)
|
||||
}
|
||||
a.GrantTypes = nil
|
||||
if err := a.UpdateCtx(tctx()); err != nil {
|
||||
t.Fatalf("withdraw grants: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// approveWithSessionOnly approves the way the approval PAGE actually does: a
|
||||
// session cookie and the user_code, and NOT one credential field. Every other
|
||||
// device test here posts organization+username+password (approveAs), which is a
|
||||
// shape the product never sends — the page exists precisely because the human is
|
||||
// already signed in.
|
||||
func approveWithSessionOnly(t *testing.T, app *zip.App, cookie, userCode string) map[string]any {
|
||||
t.Helper()
|
||||
req := jsonReq("POST", PathLogin, map[string]string{
|
||||
"type": "device", "userCode": userCode, "application": "hanzo-app",
|
||||
})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
_, body := do(t, app, req)
|
||||
return decode(t, body)
|
||||
}
|
||||
|
||||
// A signed-in human approves a device WITHOUT re-entering a password.
|
||||
//
|
||||
// This is the whole RFC 8628 terminal leg and it was broken: the session branch
|
||||
// in login.go was gated to type=code, so a credential-less type=device post fell
|
||||
// through to the credential check and answered "organization, username and
|
||||
// password are required" — with HTTP 200, so nothing looked wrong. `hanzo login`
|
||||
// hung at "Waiting for approval…" forever and no test noticed, because every
|
||||
// device test approved with a full password.
|
||||
func TestDevice_ApproveFromSessionWithoutCredentials(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
cookie := sessionCookieFor(t, app)
|
||||
if m := approveWithSessionOnly(t, app, cookie, userCode); m["status"] != "ok" {
|
||||
t.Fatalf("a signed-in human must approve without re-entering a password, got: %v", m)
|
||||
}
|
||||
|
||||
// The approval binds the SESSION's identity onto the row — the same binding
|
||||
// the credentialed path makes, so the device is signed in as the approver.
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if row == nil || row.User != "hanzo/alice" {
|
||||
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
|
||||
}
|
||||
|
||||
// And the device's poll now completes, which is the point of the whole flow.
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("poll after session approval: status %d: %v", resp.StatusCode, m)
|
||||
}
|
||||
if access, _ := m["access_token"].(string); access == "" {
|
||||
t.Fatalf("an approved device must mint a token: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous is still refused. Dropping the type=code restriction must not turn
|
||||
// the approval endpoint into one an unauthenticated caller can drive: without a
|
||||
// session there is no identity to bind, and a device that could be approved by
|
||||
// nobody would be a device anybody could take over.
|
||||
func TestDevice_ApproveWithoutSessionIsRefused(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
// No Cookie header at all.
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"type": "device", "userCode": userCode, "application": "hanzo-app",
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("anonymous device approval must be refused, got: %v", m)
|
||||
}
|
||||
|
||||
// Nothing was bound, and the device is still waiting.
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if row != nil && row.User != "" {
|
||||
t.Fatalf("a refused approval must bind nobody, got user=%q", row.User)
|
||||
}
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode == 200 {
|
||||
t.Fatalf("an unapproved device must not mint: %v", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Discovery is served at both well-known paths, host-relative, advertising only
|
||||
// what iam implements — matching the live hanzo.id surface so a client's
|
||||
// discovery step is unchanged across the backend swap.
|
||||
func TestDiscovery_ShapeAtBothPaths(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
|
||||
for _, path := range []string{PathDiscovery, PathDiscoveryV1} {
|
||||
resp, body := do(t, app, formReqNoBody("GET", path))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("%s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
d := decode(t, body)
|
||||
if d["issuer"] != "https://hanzo.id" {
|
||||
t.Errorf("%s: issuer = %v, want https://hanzo.id", path, d["issuer"])
|
||||
}
|
||||
if d["authorization_endpoint"] != "https://hanzo.id"+PathAuthorize {
|
||||
t.Errorf("%s: authorization_endpoint = %v", path, d["authorization_endpoint"])
|
||||
}
|
||||
if d["token_endpoint"] != "https://hanzo.id"+PathToken {
|
||||
t.Errorf("%s: token_endpoint = %v", path, d["token_endpoint"])
|
||||
}
|
||||
if d["userinfo_endpoint"] != "https://hanzo.id"+PathUserInfo {
|
||||
t.Errorf("%s: userinfo_endpoint = %v", path, d["userinfo_endpoint"])
|
||||
}
|
||||
if d["jwks_uri"] != "https://hanzo.id"+PathJWKS {
|
||||
t.Errorf("%s: jwks_uri = %v", path, d["jwks_uri"])
|
||||
}
|
||||
if !containsStr(d["code_challenge_methods_supported"], "S256") {
|
||||
t.Errorf("%s: S256 not advertised", path)
|
||||
}
|
||||
if containsStr(d["code_challenge_methods_supported"], "plain") {
|
||||
t.Errorf("%s: plain must never be advertised", path)
|
||||
}
|
||||
for _, alg := range []string{"RS256", "ES256", "MLDSA65"} {
|
||||
if !containsStr(d["id_token_signing_alg_values_supported"], alg) {
|
||||
t.Errorf("%s: signing alg %s not advertised", path, alg)
|
||||
}
|
||||
}
|
||||
for _, gt := range []string{"authorization_code", "refresh_token", "client_credentials"} {
|
||||
if !containsStr(d["grant_types_supported"], gt) {
|
||||
t.Errorf("%s: grant %s not advertised", path, gt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The issuer NEVER follows X-Forwarded-Host: it is resolved from the TRUSTED
|
||||
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host) through the pinned
|
||||
// issuer resolver, so a client-supplied X-Forwarded-Host cannot steer `iss`. Here
|
||||
// the trusted host is hanzo.id (formReqNoBody) and no issuer map is configured, so
|
||||
// the spoofed header is discarded and the issuer stays host-relative to hanzo.id.
|
||||
func TestDiscovery_IssuerIgnoresForwardedHost(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
req := formReqNoBody("GET", PathDiscovery)
|
||||
req.Header.Set("X-Forwarded-Host", "id.example.test")
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if got := decode(t, body)["issuer"]; got != "https://hanzo.id" {
|
||||
t.Fatalf("issuer = %v, want https://hanzo.id (X-Forwarded-Host must not steer iss)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(v any, want string) bool {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// Identity federation — iam as an OIDC/OAuth2 Relying Party to external IdPs.
|
||||
//
|
||||
// A social sign-in is a DETOUR inside the ordinary authorization-code flow. The
|
||||
// authorize endpoint, having already validated the client and its EXACT
|
||||
// redirect_uri (so there is a trusted target before anything is trusted), hands
|
||||
// a request that names a `provider` to beginFederation, which stashes the whole
|
||||
// app-leg request server-side and sends the browser to the IdP. When the IdP
|
||||
// returns to the fixed callback, iam verifies the response, LINKS or PROVISIONS
|
||||
// a local user, and mints ITS OWN authorization code — bound to the original
|
||||
// PKCE challenge, redirect_uri, and nonce — exactly as a password login would.
|
||||
// The relying party's existing PKCE code→token exchange then completes unchanged.
|
||||
//
|
||||
// The whole surface lives on the PUBLIC group (before the Guard): it
|
||||
// self-authenticates through the single-use, browser-bound, expiring state, not
|
||||
// a bearer. Every failure is fail-closed; no IdP token or secret is ever logged.
|
||||
|
||||
// PathFederationCallback is the fixed IdP return endpoint. One callback for every
|
||||
// provider — the provider is recovered from the server-side transaction the
|
||||
// state keys, never from a spoofable URL segment. It is the redirect_uri iam
|
||||
// registers with each external IdP.
|
||||
const PathFederationCallback = "/v1/iam/oauth/callback"
|
||||
|
||||
// PathMfaVerify is the hosted 2FA PAGE (a route in the SPA, not an API path) the
|
||||
// federation callback sends a second-factor-enrolled user's browser to. The page
|
||||
// collects the factor and POSTs it to PathFederationMfa; the challenge id rides
|
||||
// the httpOnly cookie the callback set, never a URL segment.
|
||||
const PathMfaVerify = "/login/mfa"
|
||||
|
||||
// fedCookieName is the per-transaction anti-forgery cookie the begin leg sets and
|
||||
// the callback checks — the browser binding that defeats login-CSRF.
|
||||
const fedCookieName = "hanzo_fed"
|
||||
|
||||
// fedStateTTL bounds how long a federation transaction (and its cookie) is
|
||||
// redeemable. Short, because it only has to survive one IdP round-trip.
|
||||
const fedStateTTL = 10 * time.Minute
|
||||
|
||||
// routeFederation registers the IdP callback on the PUBLIC group r. GET only: the
|
||||
// IdP returns via a top-level browser redirect (Google/GitHub), on which the
|
||||
// SameSite=Lax browser-binding cookie IS sent. A cross-site form_post (POST) would
|
||||
// NOT carry a Lax cookie, so the bind check would fail closed — rather than ship a
|
||||
// half-working POST path, form_post support is a deliberate future change (it needs
|
||||
// SameSite=None + its own CSRF analysis). The callback self-authenticates via the
|
||||
// single-use state + the browser cookie.
|
||||
func routeFederation(r zip.Router, db orm.DB) {
|
||||
r.Get(PathFederationCallback, federationCallbackHandler(db))
|
||||
}
|
||||
|
||||
// beginFederation starts an Authorization-Code federation. It is entered from
|
||||
// authorizeHandler ONLY after the client_id and exact redirect_uri are validated
|
||||
// and the response_type/PKCE policy is enforced, so a protocol error may now be
|
||||
// redirected to the trusted redirect_uri (RFC 6749 §4.1.2.1). It resolves the
|
||||
// named provider, mints a single-use transaction, sets the browser-binding
|
||||
// cookie, and sends the browser to the IdP.
|
||||
func beginFederation(c *zip.Ctx, db orm.DB, app *schema.Application, q authorizeRequest, method string) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// A federated (external) identity may never be minted into a reserved system
|
||||
// org (the SuperAdmin vector) nor into a tenant an attacker-owned app has no
|
||||
// right to serve. Refuse BEFORE starting the round-trip (fail fast, no IdP
|
||||
// traffic) — defense in depth behind the application-write org authorization.
|
||||
if !federationOrgAllowed(app) {
|
||||
return authorizeErrorRedirect(c, q, "access_denied", "federation is not permitted for this application")
|
||||
}
|
||||
|
||||
store.EnrichProviders(ctx, db, app)
|
||||
prov := federationProvider(app, q.provider)
|
||||
if prov == nil {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "unknown or unavailable provider")
|
||||
}
|
||||
if idpKind(prov) == "" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "provider is not a supported federation type")
|
||||
}
|
||||
if _, ok := connectorFor(prov.Type); !ok {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "provider has no local identity binding")
|
||||
}
|
||||
|
||||
state, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
verifier, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
nonce, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
bindSecret, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
|
||||
now := nowFunc()
|
||||
st := &schema.FederationState{
|
||||
Owner: providerOwner(prov),
|
||||
Name: state,
|
||||
CreatedTime: now.UTC().Format(time.RFC3339),
|
||||
Provider: prov.Name,
|
||||
ClientId: q.clientID,
|
||||
RedirectUri: q.redirectURI,
|
||||
AppState: q.state,
|
||||
Scope: q.scope,
|
||||
AppNonce: q.nonce,
|
||||
CodeChallenge: q.codeChallenge,
|
||||
CodeChallengeMethod: method,
|
||||
Resource: q.resource,
|
||||
IdpVerifier: verifier,
|
||||
IdpNonce: nonce,
|
||||
BindHash: hashToken(bindSecret),
|
||||
ExpireIn: now.Add(fedStateTTL).Unix(),
|
||||
}
|
||||
|
||||
// Build the IdP authorize URL BEFORE persisting so a discovery/config failure
|
||||
// never leaves an orphaned transaction row.
|
||||
idpURL, err := idpAuthorizeURL(ctx, prov, st, federationCallbackURL(c))
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "temporarily_unavailable", "the identity provider is unavailable")
|
||||
}
|
||||
if err := store.PersistFederationState(ctx, db, st); err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
setBindCookie(c, bindSecret)
|
||||
return c.Redirect(302, idpURL)
|
||||
}
|
||||
|
||||
// federationCallbackHandler completes the round-trip: it resolves and burns the
|
||||
// single-use transaction (checking expiry + browser binding), exchanges and
|
||||
// verifies the IdP response, links or provisions the local user, and mints the
|
||||
// iam authorization code the relying party expects — then redirects to the
|
||||
// original redirect_uri with code + state.
|
||||
func federationCallbackHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
state := param(c, "state")
|
||||
if state == "" {
|
||||
return authorizeUserError(c, "missing state")
|
||||
}
|
||||
st, err := store.GetFederationState(ctx, db, state)
|
||||
if err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
// Until the state resolves there is NO trusted redirect target, so an
|
||||
// invalid/expired/replayed state is answered in place, never redirected.
|
||||
if st == nil || st.Used || (st.ExpireIn != 0 && now.Unix() > st.ExpireIn) {
|
||||
return authorizeUserError(c, "the federation session is invalid or expired")
|
||||
}
|
||||
// Browser binding: the callback must present the same anti-forgery cookie
|
||||
// the begin leg set in THIS browser (constant-time) — the login-CSRF /
|
||||
// session-fixation defense. A stolen or injected state without the cookie
|
||||
// stops here.
|
||||
raw := readBindCookie(c)
|
||||
if raw == "" || subtle.ConstantTimeCompare([]byte(hashToken(raw)), []byte(st.BindHash)) != 1 {
|
||||
return authorizeUserError(c, "the federation session could not be verified")
|
||||
}
|
||||
// Burn the transaction now (single-use), ATOMICALLY: the find-and-burn runs under
|
||||
// a row lock (GetForUpdate), so two concurrent callbacks on one state cannot both
|
||||
// win — the loser is refused (mirrors the wallet challenge burn / TakeChallenge).
|
||||
// A replay finds it spent. The bind-cookie check above already gated this request,
|
||||
// so a CSRF-failed replay never reaches the burn.
|
||||
if _, err := store.BurnFederationState(ctx, db, state, now); err != nil {
|
||||
if errors.Is(err, store.ErrFederationConsumed) {
|
||||
return authorizeUserError(c, "the federation session is invalid or expired")
|
||||
}
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
st.Used = true
|
||||
clearBindCookie(c)
|
||||
|
||||
// Resolve the relying-party app (the trusted redirect target) and re-check
|
||||
// its redirect_uri against the live allow-list — never trust the stored
|
||||
// value blindly (defense in depth against a tampered row).
|
||||
app, err := store.GetApplicationByClientId(ctx, db, st.ClientId)
|
||||
if err != nil || app == nil {
|
||||
return authorizeUserError(c, "the client application is unavailable")
|
||||
}
|
||||
if !app.IsRedirectUriValid(st.RedirectUri) {
|
||||
return authorizeUserError(c, "invalid redirect_uri")
|
||||
}
|
||||
// Re-assert the reserved-org / tenant-legitimacy gate at the mint boundary,
|
||||
// never trusting that the begin leg still holds or that the app row is honest.
|
||||
if !federationOrgAllowed(app) {
|
||||
return fedErrorRedirect(c, st, "access_denied", "federation is not permitted for this application")
|
||||
}
|
||||
prov, err := store.GetProvider(ctx, db, st.Owner, st.Provider)
|
||||
if err != nil || prov == nil {
|
||||
return fedErrorRedirect(c, st, "temporarily_unavailable", "the identity provider is unavailable")
|
||||
}
|
||||
|
||||
// An IdP-reported denial (user declined / error) is surfaced to the RP as
|
||||
// access_denied, not a server error.
|
||||
if e := param(c, "error"); e != "" {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the identity provider denied the request")
|
||||
}
|
||||
code := param(c, "code")
|
||||
if code == "" {
|
||||
return fedErrorRedirect(c, st, "invalid_request", "the identity provider returned no code")
|
||||
}
|
||||
|
||||
identity, err := idpExchange(ctx, prov, st, code, federationCallbackURL(c), now)
|
||||
if err != nil || identity.subject == "" {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the identity provider could not be verified")
|
||||
}
|
||||
|
||||
user, err := linkOrProvision(ctx, db, app, prov, identity)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
if user.IsForbidden || user.IsDeleted {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the account is not permitted")
|
||||
}
|
||||
|
||||
// The resume parameters — the ORIGINAL authorize request — pinned so the mint
|
||||
// (now, or after a second factor) uses exactly these and nothing a later
|
||||
// request could supply.
|
||||
p := fedResumeParams{
|
||||
ClientId: st.ClientId,
|
||||
RedirectUri: st.RedirectUri,
|
||||
AppState: st.AppState,
|
||||
Scope: st.Scope,
|
||||
AppNonce: st.AppNonce,
|
||||
CodeChallenge: st.CodeChallenge,
|
||||
CodeChallengeMethod: st.CodeChallengeMethod,
|
||||
Resource: st.Resource,
|
||||
}
|
||||
|
||||
// Second-factor gate: a federated login must NOT skip the factor a password
|
||||
// login would demand (the MFA gate, mfa_gate.go). If the resolved user owes a
|
||||
// factor, mint NOTHING here — park the resume, bound to the user and these
|
||||
// pinned params, and send the browser to the hosted 2FA page.
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
if factor.Prompt(org, user) {
|
||||
// The organization requires a factor this federated user has not enrolled;
|
||||
// a federated login cannot enroll one inline, so it fails closed.
|
||||
return fedErrorRedirect(c, st, "access_denied", "two-factor authentication must be set up before signing in")
|
||||
}
|
||||
if factor.Enabled(user) && !remembered(user, now) {
|
||||
return federationChallenge(c, db, st, user, p, now)
|
||||
}
|
||||
|
||||
// No second factor owed — complete exactly as before, through the one mint.
|
||||
loc, err := federationMint(ctx, db, app, user, p, now)
|
||||
if err != nil {
|
||||
return fedMintErrorRedirect(c, st, err)
|
||||
}
|
||||
return c.Redirect(302, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// fedResumeParams is the ORIGINAL iam authorize request, pinned server-side so
|
||||
// the code minted after a federated login (immediately, or after a second factor)
|
||||
// binds to exactly these values — never to anything a later request supplies.
|
||||
type fedResumeParams struct {
|
||||
ClientId string `json:"clientId"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
AppState string `json:"appState"`
|
||||
Scope string `json:"scope"`
|
||||
AppNonce string `json:"appNonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
// errPKCERequired is the one distinguished mint error a caller maps to an OAuth
|
||||
// invalid_request; every other mint failure is an opaque server_error.
|
||||
var errPKCERequired = errors.New("federation: PKCE is required for public clients")
|
||||
|
||||
// federationMint mints iam's own authorization code — the SAME artifact a
|
||||
// password login mints — bound to the pinned app-leg PKCE, redirect_uri and nonce,
|
||||
// and returns the RP redirect (redirect_uri?code&state). It is the ONE mint path
|
||||
// both the no-factor completion and the post-2FA resume reach, so a federated code
|
||||
// can never be minted two different ways.
|
||||
func federationMint(ctx context.Context, db orm.DB, app *schema.Application, user *schema.User, p fedResumeParams, now time.Time) (string, error) {
|
||||
// A public client must have carried a PKCE challenge, re-asserted at the mint so
|
||||
// a minted code is never redeemable without proof.
|
||||
if app.ClientSecret == "" && p.CodeChallenge == "" {
|
||||
return "", errPKCERequired
|
||||
}
|
||||
userID := user.Owner + "/" + user.Name
|
||||
codeRow, err := MintCode(app, userID, p.Scope, p.CodeChallenge, p.CodeChallengeMethod, p.Resource, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
codeRow.RedirectUri = p.RedirectUri
|
||||
codeRow.Nonce = p.AppNonce
|
||||
if err := store.PersistToken(ctx, db, codeRow); err != nil {
|
||||
return "", err
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("code", codeRow.Code)
|
||||
setIfPresent(v, "state", p.AppState)
|
||||
return joinQuery(p.RedirectUri, v), nil
|
||||
}
|
||||
|
||||
// federationChallenge parks a resolved-but-not-yet-second-factored federated login.
|
||||
// The pending state IS a LoginChallenge (KindFederation) — the same single-use,
|
||||
// expiring, subject-pinned lifecycle the password MFA gate uses, so there is ONE
|
||||
// challenge concept — carrying the resume params as its payload. The browser is
|
||||
// sent to the hosted 2FA page; the challenge id rides the httpOnly cookie, never a
|
||||
// URL segment.
|
||||
func federationChallenge(c *zip.Ctx, db orm.DB, st *schema.FederationState, user *schema.User, p fedResumeParams, now time.Time) error {
|
||||
payload, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
id, err := MintChallenge(c.Context(), db, KindFederation, user.Owner+"/"+user.Name, string(payload), now)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
SetChallenge(c, id)
|
||||
return c.Redirect(302, federationBaseURL(c)+PathMfaVerify)
|
||||
}
|
||||
|
||||
// fedMintErrorRedirect maps a federationMint error to the RP redirect_uri.
|
||||
func fedMintErrorRedirect(c *zip.Ctx, st *schema.FederationState, err error) error {
|
||||
if err == errPKCERequired {
|
||||
return fedErrorRedirect(c, st, "invalid_request", "PKCE is required for public clients")
|
||||
}
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
|
||||
// linkOrProvision resolves the local identity for a verified federated login,
|
||||
// PROVISION-DON'T-PROMOTE: (1) an account already linked to this provider
|
||||
// subject, else (2) an existing account matched by a VERIFIED IdP email (linked
|
||||
// now), else (3) a freshly provisioned account. It NEVER sets isAdmin and never
|
||||
// grants an existing account anything — federation only authenticates.
|
||||
func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, id federatedIdentity) (*schema.User, error) {
|
||||
// Innermost guard on the mint itself: never provision/link a federated identity
|
||||
// into a reserved system org (SuperAdmin) or a tenant this app may not serve.
|
||||
// This layer assumes the two before it (app-write authorization + the begin/
|
||||
// callback checks) both failed.
|
||||
if !federationOrgAllowed(app) {
|
||||
return nil, errors.New("federation: provisioning into this organization is not permitted")
|
||||
}
|
||||
org := app.Organization
|
||||
binding, ok := connectorFor(prov.Type)
|
||||
if !ok {
|
||||
return nil, errors.New("federation: provider has no local identity binding")
|
||||
}
|
||||
|
||||
// 1. Already linked by the provider's stable subject — the authoritative match
|
||||
// for a returning federated user (immune to email churn/ambiguity).
|
||||
if u, err := store.GetUserByConnector(ctx, db, org, binding.field, id.subject); err != nil {
|
||||
return nil, err
|
||||
} else if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// 2. Link to an existing account ONLY on a VERIFIED IdP email. An unverified
|
||||
// email never links (it would let an unproven address take over an account).
|
||||
if id.emailVerified && id.email != "" {
|
||||
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(_ orm.DB, fresh *schema.User) error {
|
||||
*binding.ref(fresh) = id.subject
|
||||
fresh.EmailVerified = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return linked, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provision a fresh account. Federated accounts carry NO password (the
|
||||
// digest stays empty, so password login fails closed) and are never admin.
|
||||
return provisionFederatedUser(ctx, db, app, prov, binding, id)
|
||||
}
|
||||
|
||||
// provisionFederatedUser creates a new federated account through the ONE
|
||||
// canonical user-create path (users.Create, no password → no login-able digest),
|
||||
// stamping the provider subject on its connector column. The username is derived
|
||||
// from the EMAIL and collision-checked; the email's verified flag is carried
|
||||
// straight from the IdP.
|
||||
//
|
||||
// What an IdP hands over is an address and a display name, and only the address
|
||||
// may become an identity: a Google profile says "Zach Kelling", which is not a
|
||||
// username in any spelling and must never be turned into one. schema.Handle takes
|
||||
// the local part; the display name reaches DisplayName and stops there.
|
||||
func provisionFederatedUser(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, binding connectorBinding, id federatedIdentity) (*schema.User, error) {
|
||||
org := app.Organization
|
||||
for attempt := 1; attempt <= federatedNameAttempts; attempt++ {
|
||||
name := federatedUsername(id.email, prov.Type, attempt)
|
||||
taken, err := userExists(ctx, db, org, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
continue
|
||||
}
|
||||
u := schema.User{
|
||||
Owner: org,
|
||||
Name: name,
|
||||
Type: "normal-user",
|
||||
DisplayName: firstNonEmpty(id.displayName, name),
|
||||
Email: id.email,
|
||||
EmailVerified: id.emailVerified,
|
||||
Avatar: id.avatar,
|
||||
SignupApplication: app.Name,
|
||||
RegisterType: "Federation",
|
||||
RegisterSource: org + "/" + prov.Name,
|
||||
}
|
||||
*binding.ref(&u) = id.subject
|
||||
return users.New(db).Create(ctx, &users.CreateInput{User: u})
|
||||
}
|
||||
return nil, errors.New("federation: could not allocate a unique username")
|
||||
}
|
||||
|
||||
// federationProvider resolves the app's ProviderItem named name to its shared
|
||||
// Provider record, requiring the link to be sign-in-enabled and configured with
|
||||
// real credentials — otherwise the request never dead-ends at the IdP.
|
||||
func federationProvider(app *schema.Application, name string) *schema.Provider {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
for _, it := range app.Providers {
|
||||
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
|
||||
continue
|
||||
}
|
||||
if !offerable(it.Provider) {
|
||||
continue
|
||||
}
|
||||
return it.Provider
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerOwner is the Provider record's owner, defaulting to the admin org where
|
||||
// providers are seeded.
|
||||
func providerOwner(p *schema.Provider) string {
|
||||
if p.Owner != "" {
|
||||
return p.Owner
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
// federationCallbackURL is the iam callback iam registers with the IdP and
|
||||
// re-presents at the token exchange. It is PINNED from config, never steered by a
|
||||
// request header, so an attacker cannot redirect the IdP leg via X-Forwarded-Host.
|
||||
func federationCallbackURL(c *zip.Ctx) string {
|
||||
return federationBaseURL(c) + PathFederationCallback
|
||||
}
|
||||
|
||||
// federationBaseURL is the pinned public origin the IdP callback is registered
|
||||
// under — the SAME per-brand issuer the tokens carry, resolved through the ONE
|
||||
// issuer resolver (issuer.go) keyed on the TRUSTED request host (c.Host(), which
|
||||
// ignores X-Forwarded-Host). So a brand's federation callback is registered at
|
||||
// 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 resolveFederationOrigin(c.Host())
|
||||
}
|
||||
|
||||
// federationOrgAllowed reports whether a federated (external) identity may be
|
||||
// provisioned or linked into the application's Organization. Two invariants,
|
||||
// both fail-closed:
|
||||
//
|
||||
// 1. NEVER a reserved system org (admin/built-in/app). A social sign-in that
|
||||
// landed a user in the admin org would make that user a SuperAdmin — the
|
||||
// critical escalation. Federation is customer sign-in; system orgs are seeded
|
||||
// / onboarded / SuperAdmin-managed, never reached by an external login.
|
||||
// 2. Tenant legitimacy. A platform app (admin/built-in-owned, and thus only
|
||||
// SuperAdmin-creatable) may serve any non-reserved tenant. A tenant-registered
|
||||
// app may only land users in the org it legitimately serves — its OWN org, a
|
||||
// shared app, or one with an explicit org-choice mode — mirroring the
|
||||
// login/signup tenant gate, so an attacker-owned app cannot mint or link
|
||||
// identities into a victim tenant.
|
||||
//
|
||||
// This is defense in depth behind the application-write authorization (authz
|
||||
// authorizes the Organization field on create/update); this layer assumes that
|
||||
// one was bypassed and still refuses the escalation.
|
||||
func federationOrgAllowed(app *schema.Application) bool {
|
||||
org := strings.TrimSpace(app.Organization)
|
||||
if org == "" || store.IsReservedOrg(org) {
|
||||
return false
|
||||
}
|
||||
if store.IsSigningCertOwner(app.Owner) {
|
||||
return true // platform app — SuperAdmin-configured, may serve any tenant
|
||||
}
|
||||
if app.IsShared || app.OrgChoiceMode != "" {
|
||||
return true
|
||||
}
|
||||
return org == app.Owner
|
||||
}
|
||||
|
||||
// fedSuccessRedirect returns the browser to the relying party's redirect_uri with
|
||||
// the iam authorization code and the original app state (RFC 6749 §4.1.2).
|
||||
func fedSuccessRedirect(c *zip.Ctx, st *schema.FederationState, code string) error {
|
||||
v := url.Values{}
|
||||
v.Set("code", code)
|
||||
setIfPresent(v, "state", st.AppState)
|
||||
return c.Redirect(302, joinQuery(st.RedirectUri, v))
|
||||
}
|
||||
|
||||
// fedErrorRedirect returns an OAuth error to the relying party's redirect_uri
|
||||
// (already allow-list-validated) with the original app state.
|
||||
func fedErrorRedirect(c *zip.Ctx, st *schema.FederationState, code, desc string) error {
|
||||
v := url.Values{}
|
||||
v.Set("error", code)
|
||||
setIfPresent(v, "error_description", desc)
|
||||
setIfPresent(v, "state", st.AppState)
|
||||
return c.Redirect(302, joinQuery(st.RedirectUri, v))
|
||||
}
|
||||
|
||||
// setBindCookie writes the per-transaction anti-forgery cookie: HttpOnly + Secure,
|
||||
// SameSite=Lax (so it IS sent on the IdP's top-level GET back to the callback),
|
||||
// scoped to the callback path, expiring with the transaction.
|
||||
func setBindCookie(c *zip.Ctx, value string) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: fedCookieName,
|
||||
Value: value,
|
||||
Path: PathFederationCallback,
|
||||
MaxAge: int(fedStateTTL / time.Second),
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// readBindCookie returns the anti-forgery cookie value, or "" when absent.
|
||||
func readBindCookie(c *zip.Ctx) string { return c.Fiber().Cookies(fedCookieName) }
|
||||
|
||||
// clearBindCookie expires the anti-forgery cookie once the transaction is
|
||||
// consumed, so it can never be replayed.
|
||||
func clearBindCookie(c *zip.Ctx) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: fedCookieName,
|
||||
Value: "",
|
||||
Path: PathFederationCallback,
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// connectorBinding ties a provider type to the User's per-connector identity
|
||||
// column: the EXACT lowercase orm/json field name to filter on and a pointer
|
||||
// accessor to read/set the stored subject.
|
||||
type connectorBinding struct {
|
||||
field string
|
||||
ref func(*schema.User) *string
|
||||
}
|
||||
|
||||
// connectorRegistry maps a provider Type to its User connector column. The field
|
||||
// name is the EXACT json/orm name (already lowercase): orm's filter lowercases
|
||||
// only the FIRST rune, so a Go field name like "GitHub" would query '$.gitHub'
|
||||
// (the tag is 'github') — passing the exact json name is the one correct way, and
|
||||
// this registry is its single source of truth. Only the connectors iam can
|
||||
// federate are listed; anything else fails closed.
|
||||
var connectorRegistry = map[string]connectorBinding{
|
||||
"google": {"google", func(u *schema.User) *string { return &u.Google }},
|
||||
"github": {"github", func(u *schema.User) *string { return &u.GitHub }},
|
||||
"gitlab": {"gitlab", func(u *schema.User) *string { return &u.Gitlab }},
|
||||
"gitee": {"gitee", func(u *schema.User) *string { return &u.Gitee }},
|
||||
"bitbucket": {"bitbucket", func(u *schema.User) *string { return &u.Bitbucket }},
|
||||
"facebook": {"facebook", func(u *schema.User) *string { return &u.Facebook }},
|
||||
"apple": {"apple", func(u *schema.User) *string { return &u.Apple }},
|
||||
"linkedin": {"linkedin", func(u *schema.User) *string { return &u.LinkedIn }},
|
||||
"discord": {"discord", func(u *schema.User) *string { return &u.Discord }},
|
||||
"slack": {"slack", func(u *schema.User) *string { return &u.Slack }},
|
||||
"okta": {"okta", func(u *schema.User) *string { return &u.Okta }},
|
||||
"azuread": {"azuread", func(u *schema.User) *string { return &u.AzureAD }},
|
||||
"microsoftonline": {"microsoftonline", func(u *schema.User) *string { return &u.MicrosoftOnline }},
|
||||
}
|
||||
|
||||
// connectorFor resolves the connector binding for a provider type (case-folded),
|
||||
// or (zero, false) when the type has no local identity column.
|
||||
func connectorFor(providerType string) (connectorBinding, bool) {
|
||||
b, ok := connectorRegistry[strings.ToLower(strings.TrimSpace(providerType))]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// federatedNameAttempts bounds the dedupe walk: the first free suffix wins, and a
|
||||
// name that is still taken after this many tries means something is wrong with the
|
||||
// derivation, not that the org is full.
|
||||
const federatedNameAttempts = 32
|
||||
|
||||
// federatedUsername derives the username for a provisioned account from the email
|
||||
// local part (schema.Handle), falling back to the provider name and then "user"
|
||||
// when nothing usable survives. attempt 1 asks for the bare handle; each later
|
||||
// attempt appends its number, so z@hanzo.ai becomes "z", then "z2", "z3" — a
|
||||
// person gets the name they would have chosen, and the suffix appears only when it
|
||||
// has to.
|
||||
//
|
||||
// It replaced a random 8-hex suffix on EVERY name ("z-3f9ab21c"), which made
|
||||
// collisions impossible by making every name unrecognisable. Collisions are the
|
||||
// caller's loop to handle; a username is meant to be typed and read.
|
||||
func federatedUsername(email, providerType string, attempt int) string {
|
||||
base := schema.Handle(email)
|
||||
if base == "" {
|
||||
// No usable address. The provider TYPE ("google", "github") is the only other
|
||||
// value here that is not a human's name — the display name is deliberately
|
||||
// never consulted, on any branch.
|
||||
base, _ = schema.Username(providerType)
|
||||
}
|
||||
if base == "" {
|
||||
base = "user"
|
||||
}
|
||||
if attempt > 1 {
|
||||
base += strconv.Itoa(attempt)
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
// Copyright 2026 Hanzo AI, Inc.
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
// The Relying-Party side of federation: iam as an OIDC/OAuth2 CLIENT of an
|
||||
// external identity provider. Two dialects, one contract (federatedIdentity):
|
||||
//
|
||||
// - OIDC (Google + any provider with an IssuerUrl): OIDC Discovery resolves the
|
||||
// endpoints and JWKS; the end user is authenticated by the id_token, whose
|
||||
// SIGNATURE (against the published JWKS), issuer, audience, expiry, and nonce
|
||||
// are all verified before a single claim is trusted. email_verified is read
|
||||
// from the signed token.
|
||||
// - GitHub (OAuth2, no id_token): the code is exchanged for an access token,
|
||||
// then the user + verified-email endpoints are read. Only a GitHub-verified,
|
||||
// primary email is treated as verified.
|
||||
//
|
||||
// Every outbound call is hardened: a bounded-timeout client that never follows
|
||||
// redirects (a 3xx on a token/JWKS endpoint is answered as a failure, not
|
||||
// chased), a response-body size cap, an https-except-loopback URL guard, and
|
||||
// alg-pinned JWT verification (RS/ES only — never `none`, never an HMAC that a
|
||||
// public key could be abused as the secret for). No secret or token is logged.
|
||||
|
||||
// federatedIdentity is the VERIFIED identity an external IdP asserts about the
|
||||
// end user — the only thing the broker trusts out of the round-trip. Subject is
|
||||
// the IdP's stable, opaque user id (the connector-column value); Email is linked
|
||||
// against a local account ONLY when EmailVerified is true.
|
||||
type federatedIdentity struct {
|
||||
subject string
|
||||
email string
|
||||
emailVerified bool
|
||||
displayName string
|
||||
avatar string
|
||||
}
|
||||
|
||||
// federationHTTPClient is the hardened client every IdP call rides. The timeout
|
||||
// bounds a slow/hostile IdP; CheckRedirect refuses to chase a redirect (an IdP
|
||||
// token/userinfo/JWKS endpoint answering 3xx is a fault, not a hop), closing the
|
||||
// SSRF-via-redirect vector; and the dialer Control refuses to connect to a
|
||||
// private/loopback/link-local/metadata address AT DIAL TIME — after DNS
|
||||
// resolution, on the ACTUAL connecting IP — so a hostile IssuerUrl/Custom*Url (or
|
||||
// a DNS-rebinding hostname) cannot make iam reach an internal service or the
|
||||
// cloud metadata endpoint.
|
||||
var federationHTTPClient = &http.Client{
|
||||
Timeout: 12 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: federationDialControl,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 8 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// federationDialAllowsPrivate relaxes the SSRF dial guard to permit
|
||||
// private/loopback addresses. It is a TEST SEAM ONLY (the mock IdPs bind to
|
||||
// 127.0.0.1); production code never sets it, so the guard is always fully armed
|
||||
// in a real deployment.
|
||||
var federationDialAllowsPrivate = false
|
||||
|
||||
// federationDialControl is the net.Dialer.Control hook: it inspects the resolved
|
||||
// address every connection actually dials and refuses a private, loopback,
|
||||
// link-local, ULA, unspecified, multicast, or CGNAT target — the SSRF gate that a
|
||||
// literal-URL check cannot provide because it sees the post-DNS IP (defeating
|
||||
// DNS-rebinding). Fails closed on an unparseable address.
|
||||
func federationDialControl(_, address string, _ syscall.RawConn) error {
|
||||
if federationDialAllowsPrivate {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return errors.New("federation: refusing an unparseable dial address")
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("federation: dial host did not resolve to an IP")
|
||||
}
|
||||
if ipBlockedForFederation(ip) {
|
||||
return errors.New("federation: refusing to dial a private/loopback/link-local address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipBlockedForFederation reports whether an IP is in a range iam must never
|
||||
// fetch from during federation. net.IP.IsPrivate covers RFC1918 and IPv6 ULA
|
||||
// (fc00::/7); IsLinkLocalUnicast covers 169.254.0.0/16 (incl. the 169.254.169.254
|
||||
// cloud-metadata address) and fe80::/10.
|
||||
func ipBlockedForFederation(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || isCGNAT(ip)
|
||||
}
|
||||
|
||||
// isCGNAT reports whether ip is in 100.64.0.0/10 (carrier-grade NAT), a shared
|
||||
// range net.IP.IsPrivate does not cover.
|
||||
func isCGNAT(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
return v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127
|
||||
}
|
||||
|
||||
// maxIdPBodyBytes caps every IdP response read — a hostile or broken IdP cannot
|
||||
// exhaust memory (discovery/JWKS/token/userinfo are all a few KB).
|
||||
const maxIdPBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// defaultGoogleIssuer is the OIDC issuer for a Google provider that pins none of
|
||||
// its own — the value the id_token carries as `iss` and the discovery origin.
|
||||
const defaultGoogleIssuer = "https://accounts.google.com"
|
||||
|
||||
// GitHub's fixed OAuth2 endpoints (overridable per-Provider for GitHub
|
||||
// Enterprise / tests via Custom{Auth,Token,UserInfo}Url).
|
||||
const (
|
||||
githubAuthorizeEndpoint = "https://github.com/login/oauth/authorize"
|
||||
githubTokenEndpoint = "https://github.com/login/oauth/access_token"
|
||||
githubUserEndpoint = "https://api.github.com/user"
|
||||
)
|
||||
|
||||
// idpKind classifies a provider into its federation dialect. A provider with an
|
||||
// explicit OIDC issuer — or Google — is OIDC; GitHub is OAuth2+userinfo.
|
||||
// Anything else is unsupported and fails closed (""), never guessed.
|
||||
func idpKind(p *schema.Provider) string {
|
||||
switch {
|
||||
case strings.EqualFold(p.Type, "GitHub"):
|
||||
return "github"
|
||||
case strings.EqualFold(p.Type, "Google") || strings.TrimSpace(p.IssuerUrl) != "":
|
||||
return "oidc"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// idpAuthorizeURL builds the IdP authorization-endpoint URL the browser is sent
|
||||
// to at the begin leg — dialect-dispatched, with iam's callback as the IdP
|
||||
// redirect_uri, our single-use state, IdP-leg PKCE, and (OIDC) the nonce.
|
||||
func idpAuthorizeURL(ctx context.Context, p *schema.Provider, st *schema.FederationState, callback string) (string, error) {
|
||||
switch idpKind(p) {
|
||||
case "oidc":
|
||||
cfg, err := oidcResolve(ctx, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return oidcAuthorizeURL(cfg, p, st, callback), nil
|
||||
case "github":
|
||||
return githubAuthorizeURL(p, st, callback), nil
|
||||
default:
|
||||
return "", fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// idpExchange completes the callback leg: it exchanges the IdP authorization
|
||||
// code and returns the VERIFIED identity, or an error if any verification fails.
|
||||
func idpExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
|
||||
switch idpKind(p) {
|
||||
case "oidc":
|
||||
cfg, err := oidcResolve(ctx, p)
|
||||
if err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
return oidcExchange(ctx, cfg, p, st, code, callback, now)
|
||||
case "github":
|
||||
return githubExchange(ctx, p, st, code, callback)
|
||||
default:
|
||||
return federatedIdentity{}, fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OIDC dialect (Google + any IssuerUrl provider) ---
|
||||
|
||||
// oidcConfig is the resolved OIDC endpoint set for a provider.
|
||||
type oidcConfig struct {
|
||||
issuer string
|
||||
authURL string
|
||||
tokenURL string
|
||||
jwksURL string
|
||||
}
|
||||
|
||||
// oidcResolve determines the issuer and runs OIDC Discovery to fill the endpoint
|
||||
// set. A per-Provider Custom{Auth,Token}Url overrides the discovered
|
||||
// authorize/token endpoint (a provider that publishes discovery but pins a
|
||||
// vanity endpoint); the JWKS URI always comes from the signed discovery document
|
||||
// so id_token verification keys are never attacker-chosen.
|
||||
func oidcResolve(ctx context.Context, p *schema.Provider) (oidcConfig, error) {
|
||||
issuer := strings.TrimRight(strings.TrimSpace(p.IssuerUrl), "/")
|
||||
if issuer == "" && strings.EqualFold(p.Type, "Google") {
|
||||
issuer = defaultGoogleIssuer
|
||||
}
|
||||
if issuer == "" {
|
||||
return oidcConfig{}, errors.New("federation: OIDC provider has no issuerUrl")
|
||||
}
|
||||
disco, err := oidcDiscover(ctx, issuer)
|
||||
if err != nil {
|
||||
return oidcConfig{}, err
|
||||
}
|
||||
cfg := oidcConfig{
|
||||
issuer: issuer,
|
||||
authURL: disco.AuthorizationEndpoint,
|
||||
tokenURL: disco.TokenEndpoint,
|
||||
jwksURL: disco.JwksURI,
|
||||
}
|
||||
if v := strings.TrimSpace(p.CustomAuthUrl); v != "" {
|
||||
cfg.authURL = v
|
||||
}
|
||||
if v := strings.TrimSpace(p.CustomTokenUrl); v != "" {
|
||||
cfg.tokenURL = v
|
||||
}
|
||||
if cfg.authURL == "" || cfg.tokenURL == "" || cfg.jwksURL == "" {
|
||||
return oidcConfig{}, errors.New("federation: OIDC discovery is missing required endpoints")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam reads.
|
||||
type oidcDiscoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
// oidcDiscover fetches and validates the issuer's discovery document. The
|
||||
// document's own `issuer` MUST equal the configured issuer (OIDC Discovery §4.3)
|
||||
// — a mismatch means the origin is impersonating another issuer, so it fails
|
||||
// closed.
|
||||
func oidcDiscover(ctx context.Context, issuer string) (oidcDiscoveryDocument, error) {
|
||||
var doc oidcDiscoveryDocument
|
||||
if err := getJSON(ctx, issuer+"/.well-known/openid-configuration", &doc); err != nil {
|
||||
return doc, err
|
||||
}
|
||||
if strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(issuer, "/") {
|
||||
return oidcDiscoveryDocument{}, fmt.Errorf("federation: discovery issuer mismatch")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// oidcAuthorizeURL builds the OIDC authorization request: response_type=code,
|
||||
// the app-or-default scope, our callback, the single-use state, S256 PKCE, and
|
||||
// the nonce that the returned id_token must echo.
|
||||
func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationState, callback string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", p.ClientId)
|
||||
v.Set("redirect_uri", callback)
|
||||
// The OIDC leg MUST request openid, or the IdP returns no id_token and the
|
||||
// exchange fails closed — force it in even if the provider's configured scopes
|
||||
// omit it, so a scope misconfiguration can never silently disable verification.
|
||||
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("nonce", st.IdpNonce)
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
return joinQuery(cfg.authURL, v)
|
||||
}
|
||||
|
||||
// oidcTokenResponse is the token-endpoint response the OIDC exchange reads.
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// oidcExchange redeems the code at the token endpoint (proving the IdP-leg PKCE
|
||||
// verifier), then VERIFIES the id_token — signature against the discovered JWKS,
|
||||
// issuer, audience (== our client id), expiry, and nonce — before trusting any
|
||||
// claim. The identity comes from the signed id_token, never from an unverified
|
||||
// userinfo body.
|
||||
func oidcExchange(ctx context.Context, cfg oidcConfig, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", callback)
|
||||
form.Set("client_id", p.ClientId)
|
||||
form.Set("client_secret", p.ClientSecret)
|
||||
form.Set("code_verifier", st.IdpVerifier)
|
||||
|
||||
var tr oidcTokenResponse
|
||||
if err := postFormJSON(ctx, cfg.tokenURL, form, nil, &tr); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if tr.IDToken == "" {
|
||||
return federatedIdentity{}, errors.New("federation: OIDC token response carried no id_token")
|
||||
}
|
||||
claims, err := verifyIDToken(ctx, tr.IDToken, cfg.jwksURL, cfg.issuer, p.ClientId, st.IdpNonce, now)
|
||||
if err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
return federatedIdentity{
|
||||
subject: claims.Subject,
|
||||
email: strings.ToLower(strings.TrimSpace(claims.Email)),
|
||||
emailVerified: truthy(claims.EmailVerified),
|
||||
displayName: claims.Name,
|
||||
avatar: claims.Picture,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// idTokenClaims is the id_token claim set iam reads. Nonce is a top-level OIDC
|
||||
// claim (not a registered JWT claim), verified against the transaction's stored
|
||||
// nonce. email_verified is `any` because providers send it as a JSON bool or
|
||||
// (legacy) the string "true".
|
||||
type idTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Nonce string `json:"nonce"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified any `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// verifyIDToken parses and fully validates an id_token. The signing method is
|
||||
// PINNED to the asymmetric set (RS/ES) so a `none` token or an HMAC-with-public-
|
||||
// key confusion attack is rejected outright; the key comes from the issuer's
|
||||
// JWKS, selected by `kid`; issuer, audience, and expiry are enforced by the
|
||||
// parser (now is injected for testability); and the nonce is compared in
|
||||
// constant time. A subject-less token is refused.
|
||||
func verifyIDToken(ctx context.Context, idToken, jwksURL, issuer, audience, nonce string, now time.Time) (idTokenClaims, error) {
|
||||
var claims idTokenClaims
|
||||
tok, err := jwt.ParseWithClaims(idToken, &claims, jwksKeyfunc(ctx, jwksURL),
|
||||
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithTimeFunc(func() time.Time { return now }),
|
||||
)
|
||||
if err != nil || !tok.Valid {
|
||||
return idTokenClaims{}, fmt.Errorf("federation: id_token verification failed")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
|
||||
return idTokenClaims{}, errors.New("federation: id_token nonce mismatch")
|
||||
}
|
||||
if strings.TrimSpace(claims.Subject) == "" {
|
||||
return idTokenClaims{}, errors.New("federation: id_token has no subject")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// --- GitHub dialect (OAuth2 + userinfo) ---
|
||||
|
||||
// githubAuthorizeURL builds GitHub's OAuth2 authorization request. GitHub OAuth
|
||||
// Apps support neither PKCE nor a nonce, so the single-use, browser-bound state
|
||||
// carries the CSRF defense; PKCE is added only when the provider opts in
|
||||
// (EnablePkce) for a compatible deployment.
|
||||
func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", p.ClientId)
|
||||
v.Set("redirect_uri", callback)
|
||||
v.Set("scope", providerScopes(p, "read:user user:email"))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("allow_signup", "true")
|
||||
if p.EnablePkce {
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
}
|
||||
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
|
||||
}
|
||||
|
||||
// githubTokenResponse is GitHub's (JSON, via Accept) token response.
|
||||
type githubTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// githubUser / githubEmail are the userinfo shapes iam reads.
|
||||
type githubUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type githubEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// githubExchange redeems the code for an access token, then reads the user and
|
||||
// the verified-email list. The subject is GitHub's immutable numeric id; an
|
||||
// email is treated as verified ONLY when GitHub reports it verified (primary
|
||||
// preferred), so a local account is never linked to an unproven address.
|
||||
func githubExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string) (federatedIdentity, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", callback)
|
||||
form.Set("client_id", p.ClientId)
|
||||
form.Set("client_secret", p.ClientSecret)
|
||||
if p.EnablePkce {
|
||||
form.Set("code_verifier", st.IdpVerifier)
|
||||
}
|
||||
|
||||
var tr githubTokenResponse
|
||||
if err := postFormJSON(ctx, firstNonEmpty(p.CustomTokenUrl, githubTokenEndpoint), form, http.Header{"Accept": {"application/json"}}, &tr); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if tr.Error != "" || tr.AccessToken == "" {
|
||||
return federatedIdentity{}, errors.New("federation: GitHub token exchange failed")
|
||||
}
|
||||
|
||||
userURL := firstNonEmpty(p.CustomUserInfoUrl, githubUserEndpoint)
|
||||
var gu githubUser
|
||||
if err := getJSONBearer(ctx, userURL, tr.AccessToken, &gu); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if gu.ID == 0 {
|
||||
return federatedIdentity{}, errors.New("federation: GitHub user has no id")
|
||||
}
|
||||
|
||||
email, verified := githubPrimaryEmail(ctx, userURL, tr.AccessToken, gu.Email)
|
||||
name := gu.Name
|
||||
if name == "" {
|
||||
name = gu.Login
|
||||
}
|
||||
return federatedIdentity{
|
||||
subject: strconv.FormatInt(gu.ID, 10),
|
||||
email: strings.ToLower(strings.TrimSpace(email)),
|
||||
emailVerified: verified,
|
||||
displayName: name,
|
||||
avatar: gu.AvatarURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// githubPrimaryEmail resolves the address to link on: the GitHub /user/emails
|
||||
// list's primary-and-verified entry (then any verified entry). It returns
|
||||
// (email, verified); when nothing is verified it returns verified=false so the
|
||||
// broker provisions a fresh account rather than link by an unproven email. A
|
||||
// failure to read the list is not fatal — it degrades to unverified.
|
||||
func githubPrimaryEmail(ctx context.Context, userURL, token, fallback string) (string, bool) {
|
||||
var emails []githubEmail
|
||||
if err := getJSONBearer(ctx, strings.TrimRight(userURL, "/")+"/emails", token, &emails); err == nil {
|
||||
var anyVerified string
|
||||
for _, e := range emails {
|
||||
if !e.Verified {
|
||||
continue
|
||||
}
|
||||
if e.Primary {
|
||||
return e.Email, true
|
||||
}
|
||||
if anyVerified == "" {
|
||||
anyVerified = e.Email
|
||||
}
|
||||
}
|
||||
if anyVerified != "" {
|
||||
return anyVerified, true
|
||||
}
|
||||
}
|
||||
// No verified address available — the profile email is unproven.
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
// --- hardened HTTP + JWKS ---
|
||||
|
||||
// getJSON GETs a URL and decodes a JSON body, with the safety guard, a 200-only
|
||||
// contract, and a body-size cap.
|
||||
func getJSON(ctx context.Context, rawURL string, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// getJSONBearer GETs a bearer-authenticated JSON endpoint (GitHub userinfo).
|
||||
func getJSONBearer(ctx context.Context, rawURL, token string, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, http.Header{
|
||||
"Authorization": {"Bearer " + token},
|
||||
"Accept": {"application/vnd.github+json"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// postFormJSON POSTs a urlencoded form and decodes a JSON body.
|
||||
func postFormJSON(ctx context.Context, rawURL string, form url.Values, header http.Header, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()), header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if req.Header.Get("Accept") == "" {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// newIdPRequest builds a context-bound request to a guard-checked URL with a
|
||||
// stable User-Agent and the caller's headers.
|
||||
func newIdPRequest(ctx context.Context, method, rawURL string, body io.Reader, header http.Header) (*http.Request, error) {
|
||||
safe, err := requireSafeURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, safe, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "hanzo-iam-federation")
|
||||
for k, vs := range header {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// doJSON executes a request and decodes a 200 JSON body under the size cap. A
|
||||
// non-200 status is a hard failure — no partial trust in an error body.
|
||||
func doJSON(req *http.Request, out any) error {
|
||||
resp, err := federationHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("federation: idp request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxIdPBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("federation: reading idp response failed")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("federation: idp returned status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("federation: decoding idp response failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireSafeURL parses rawURL and enforces the transport guard: http(s) only,
|
||||
// a non-empty host, and https EXCEPT for loopback (so the production path is
|
||||
// always TLS while tests may target 127.0.0.1). This also rejects file://, and
|
||||
// any non-web scheme — an SSRF/exfiltration hygiene gate on the (admin-supplied)
|
||||
// endpoint configuration.
|
||||
func requireSafeURL(rawURL string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("federation: invalid idp url")
|
||||
}
|
||||
if u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("federation: idp url must be http(s) with a host")
|
||||
}
|
||||
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
|
||||
return "", fmt.Errorf("federation: idp url must use https")
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// isLoopbackHost reports whether host is a loopback name/address.
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jwkSet / jwk are the JSON Web Key Set shapes iam verifies id_tokens against.
|
||||
type jwkSet struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
// jwksKeyfunc returns a jwt.Keyfunc that fetches the issuer's JWKS and selects
|
||||
// the verification key by the token's `kid`. When the token carries a kid, an
|
||||
// exact match is required; a kid-less token is accepted only against a
|
||||
// single-key set. The fetch happens inside the closure so it is bounded by the
|
||||
// same hardened client and request context.
|
||||
func jwksKeyfunc(ctx context.Context, jwksURL string) jwt.Keyfunc {
|
||||
return func(t *jwt.Token) (any, error) {
|
||||
var set jwkSet
|
||||
if err := getJSON(ctx, jwksURL, &set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
if len(set.Keys) != 1 {
|
||||
return nil, errors.New("federation: id_token has no kid and JWKS is not single-key")
|
||||
}
|
||||
return set.Keys[0].publicKey()
|
||||
}
|
||||
for _, k := range set.Keys {
|
||||
if k.Kid == kid {
|
||||
return k.publicKey()
|
||||
}
|
||||
}
|
||||
return nil, errors.New("federation: no JWKS key matches the id_token kid")
|
||||
}
|
||||
}
|
||||
|
||||
// publicKey materializes a JWK into a crypto public key (RSA or EC). Only the
|
||||
// two families iam signs with are supported; any other key type is refused.
|
||||
func (k jwk) publicKey() (any, error) {
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
n, err := b64uBigInt(k.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "="))
|
||||
if err != nil {
|
||||
return nil, errors.New("federation: bad JWKS RSA exponent")
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eb {
|
||||
e = e<<8 | int(b)
|
||||
}
|
||||
if e == 0 {
|
||||
return nil, errors.New("federation: zero JWKS RSA exponent")
|
||||
}
|
||||
return &rsa.PublicKey{N: n, E: e}, nil
|
||||
case "EC":
|
||||
curve, err := ecCurve(k.Crv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, err := b64uBigInt(k.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := b64uBigInt(k.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("federation: unsupported JWKS key type %q", k.Kty)
|
||||
}
|
||||
}
|
||||
|
||||
// ecCurve maps a JWK curve name to its elliptic.Curve.
|
||||
func ecCurve(crv string) (elliptic.Curve, error) {
|
||||
switch crv {
|
||||
case "P-256":
|
||||
return elliptic.P256(), nil
|
||||
case "P-384":
|
||||
return elliptic.P384(), nil
|
||||
case "P-521":
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("federation: unsupported JWKS curve %q", crv)
|
||||
}
|
||||
}
|
||||
|
||||
// b64uBigInt decodes a base64url (unpadded) big-endian integer — the JWK
|
||||
// encoding for RSA modulus/exponent and EC coordinates.
|
||||
func b64uBigInt(s string) (*big.Int, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return nil, errors.New("federation: bad JWKS integer encoding")
|
||||
}
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// --- small shared helpers ---
|
||||
|
||||
// providerScopes returns the provider's configured scopes, or a dialect default
|
||||
// when it configures none.
|
||||
func providerScopes(p *schema.Provider, fallback string) string {
|
||||
if s := strings.TrimSpace(p.Scopes); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ensureOpenID guarantees the space-delimited scope contains "openid" (the OIDC
|
||||
// requirement for an id_token), prepending it when absent.
|
||||
func ensureOpenID(scope string) string {
|
||||
for _, s := range strings.Fields(scope) {
|
||||
if s == "openid" {
|
||||
return scope
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace("openid " + scope)
|
||||
}
|
||||
|
||||
// joinQuery appends encoded query values to a base URL, honoring an existing
|
||||
// query string.
|
||||
func joinQuery(base string, v url.Values) string {
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return base + sep + v.Encode()
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truthy interprets an id_token email_verified value (bool or string form).
|
||||
func truthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(t, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user