@openparachute/hub 0.5.13 → 0.5.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/oauth-handlers.ts
CHANGED
|
@@ -83,7 +83,7 @@ import {
|
|
|
83
83
|
findSession,
|
|
84
84
|
parseSessionCookie,
|
|
85
85
|
} from "./sessions.ts";
|
|
86
|
-
import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
|
|
86
|
+
import { getUserById, getUserByUsername, isFirstAdmin, verifyPassword } from "./users.ts";
|
|
87
87
|
import { listVaultNames } from "./vault-names.ts";
|
|
88
88
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
89
89
|
|
|
@@ -114,30 +114,35 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
116
|
* Derive the `vault_scope` claim value for a given hub user. Multi-user
|
|
117
|
-
* Phase
|
|
118
|
-
*
|
|
119
|
-
* §oauth-claim-shape).
|
|
117
|
+
* Phase 2 PR 2 (design 2026-05-20-multi-user-phase-1.md §Phase 2 —
|
|
118
|
+
* many-to-many membership via the `user_vaults` table).
|
|
120
119
|
*
|
|
121
120
|
* - `userId` resolves to no row → `[]`. Defensive: the caller already
|
|
122
121
|
* validated the user existed (auth-code redemption / refresh row's
|
|
123
|
-
* user_id), but a delete-between-mint-and-now race shouldn't 500.
|
|
124
|
-
* is the safe sentinel — the scope-bearing `scope` claim is
|
|
125
|
-
* gate.
|
|
126
|
-
* -
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* vault
|
|
122
|
+
* user_id), but a delete-between-mint-and-now race shouldn't 500.
|
|
123
|
+
* Empty is the safe sentinel — the scope-bearing `scope` claim is
|
|
124
|
+
* still the gate.
|
|
125
|
+
* - First admin → `[]`. Admin posture is unrestricted by design (see
|
|
126
|
+
* `isFirstAdmin`). The consent picker is the source of truth and
|
|
127
|
+
* the scope-guard reads an empty `vault_scope` claim as "no
|
|
128
|
+
* narrowing" — first admin can request scope against any vault.
|
|
129
|
+
* - Non-admin user → the list of vault names from `user_vaults`. The
|
|
130
|
+
* scope-guard at vault/notes/scribe enforces that the user can
|
|
131
|
+
* only request scope against vaults in their list (Phase 1 pinned
|
|
132
|
+
* to a single vault; Phase 2 lifts that to N). A non-admin with
|
|
133
|
+
* zero assignments returns `[]` — distinct semantics from the
|
|
134
|
+
* admin's `[]` because the consent picker plus the picked-must-
|
|
135
|
+
* match-assignment defense in `handleConsentSubmit` enforces that
|
|
136
|
+
* non-admin tokens carry a non-empty `vault_scope`.
|
|
132
137
|
*
|
|
133
138
|
* Always returns an array (never undefined) so the JWT carries the claim
|
|
134
139
|
* unconditionally — readers don't have to distinguish "absent" from "empty."
|
|
135
140
|
*/
|
|
136
141
|
export function vaultScopeForUser(db: Database, userId: string): string[] {
|
|
142
|
+
if (isFirstAdmin(db, userId)) return [];
|
|
137
143
|
const user = getUserById(db, userId);
|
|
138
144
|
if (!user) return [];
|
|
139
|
-
|
|
140
|
-
return [user.assignedVault];
|
|
145
|
+
return [...user.assignedVaults];
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
export interface OAuthDeps {
|
|
@@ -862,12 +867,20 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
862
867
|
return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
|
|
863
868
|
}
|
|
864
869
|
|
|
865
|
-
// Multi-user Phase
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
870
|
+
// Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
|
|
871
|
+
// their assigned vault list — they can't pick a vault they don't own.
|
|
872
|
+
// First admin (admin posture) sees the full dropdown of every vault on
|
|
873
|
+
// the hub.
|
|
874
|
+
//
|
|
875
|
+
// Two shapes for non-admin users emerge:
|
|
876
|
+
// - exactly one assigned vault → picker renders locked to that name
|
|
877
|
+
// (same shape as Phase 1; smallest diff for the common case).
|
|
878
|
+
// - two or more assigned vaults → picker renders a free dropdown
|
|
879
|
+
// filtered to those names — user picks one per consent.
|
|
880
|
+
//
|
|
881
|
+
// Defensive null-coalesce: the session points at a deleted user
|
|
882
|
+
// shouldn't 500; treat as admin posture (the broader scope-validation
|
|
883
|
+
// gate will catch any actual privilege issue).
|
|
871
884
|
//
|
|
872
885
|
// Resolved here (before the fast-paths) because the stale-assignment
|
|
873
886
|
// predicate below — which gates both skip-consent (#75) and same-hub
|
|
@@ -876,24 +889,28 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
876
889
|
// closing the silent-mint-on-stale-vault gap; the read is one JSON parse
|
|
877
890
|
// off-disk per /authorize.
|
|
878
891
|
const user = getUserById(db, session.userId);
|
|
879
|
-
const
|
|
892
|
+
const userIsAdmin = isFirstAdmin(db, session.userId);
|
|
893
|
+
// Non-admin user's assigned vaults; admin posture (or no row) → empty.
|
|
894
|
+
const assignedVaults: string[] = userIsAdmin ? [] : (user?.assignedVaults ?? []);
|
|
880
895
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
881
896
|
const vaultNames = listVaultNames(manifest);
|
|
882
897
|
|
|
883
|
-
// Stale-assignment predicate (hub#284
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
898
|
+
// Stale-assignment predicate (hub#284, generalized in Phase 2 PR 2 from
|
|
899
|
+
// single-vault to N-vault). For a non-admin user, "stale" means at
|
|
900
|
+
// least one of their assigned vaults no longer exists in services.json
|
|
901
|
+
// AND no vault in their list still exists — i.e. they have *zero*
|
|
902
|
+
// valid vaults to consent against. The banner surfaces this state with
|
|
903
|
+
// an admin-remediation hint instead of silently minting a token
|
|
904
|
+
// against a missing vault. If at least one of their vaults still
|
|
905
|
+
// exists, the consent flow proceeds normally — the missing ones drop
|
|
906
|
+
// out of the picker without ceremony.
|
|
907
|
+
//
|
|
908
|
+
// Admin users are never stale (they aren't pinned to any vault list).
|
|
909
|
+
const remainingValidVaults = assignedVaults.filter((v) => vaultNames.includes(v));
|
|
910
|
+
const hasStaleAssignment = assignedVaults.length > 0 && remainingValidVaults.length === 0;
|
|
894
911
|
|
|
895
912
|
// Skip-consent gate (#75). If the user has previously granted every
|
|
896
|
-
// requested scope to this client, mint the auth code immediately.
|
|
913
|
+
// requested scope to this client, mint the auth code immediately. Three
|
|
897
914
|
// important constraints:
|
|
898
915
|
// - Unnamed vault verbs (`vault:read`) need the picker even if a prior
|
|
899
916
|
// grant exists, because the operator's vault choice isn't recorded
|
|
@@ -905,10 +922,25 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
905
922
|
// - Stale-assignment (above) also forces the consent render so the
|
|
906
923
|
// banner explains the broken state rather than silently minting a
|
|
907
924
|
// token against the missing vault.
|
|
925
|
+
// - The user is admin OR has at least one assigned vault (hub#429
|
|
926
|
+
// reviewer fold, follow-up). A zero-vault non-admin whose prior
|
|
927
|
+
// grants survived a `setUserVaults(_, [])` admin action would
|
|
928
|
+
// otherwise silently re-mint a token against the now-revoked
|
|
929
|
+
// vault assignment — the grants table has no FK cascade from
|
|
930
|
+
// `user_vaults`, so deleting assignments doesn't revoke grants.
|
|
931
|
+
// Same privesc shape as the same-hub auto-trust gate below;
|
|
932
|
+
// identical guard (`userHasVaultPosture`). Force fall-through to
|
|
933
|
+
// the consent render where the zero-vault gate in
|
|
934
|
+
// `handleConsentSubmit` also refuses (defense in depth). This
|
|
935
|
+
// also transitively defends the trust-by-client_name auto-
|
|
936
|
+
// promote path (~line 554) which recursively re-enters
|
|
937
|
+
// `handleAuthorizeGet` after promoting the pending client.
|
|
908
938
|
const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
|
|
939
|
+
const userHasVaultPosture = userIsAdmin || assignedVaults.length > 0;
|
|
909
940
|
if (
|
|
910
941
|
!hasStaleAssignment &&
|
|
911
942
|
!hasUnnamedVault &&
|
|
943
|
+
userHasVaultPosture &&
|
|
912
944
|
isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)
|
|
913
945
|
) {
|
|
914
946
|
console.log(
|
|
@@ -931,16 +963,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
931
963
|
// still want explicit consent as a sanity gate.
|
|
932
964
|
// 3. No unnamed vault verbs are requested — those need the picker to
|
|
933
965
|
// narrow `vault:<verb>` → `vault:<name>:<verb>` before mint.
|
|
934
|
-
// 4. The user's
|
|
935
|
-
// otherwise the same-hub gate would silently mint a token
|
|
936
|
-
// removed vault before the consent-render path's stale
|
|
937
|
-
// ever runs.
|
|
966
|
+
// 4. The user's assigned_vaults list is not stale (hub#284 reviewer
|
|
967
|
+
// fold) — otherwise the same-hub gate would silently mint a token
|
|
968
|
+
// for a removed vault before the consent-render path's stale
|
|
969
|
+
// detection ever runs.
|
|
970
|
+
// 5. The user is admin OR has at least one assigned vault (Phase 2
|
|
971
|
+
// PR 2 reviewer fold). A zero-vault non-admin has
|
|
972
|
+
// `hasStaleAssignment=false` (length===0 short-circuits the stale
|
|
973
|
+
// predicate above) and would otherwise sail through the auto-
|
|
974
|
+
// trust gate. The resulting `vault_scope: []` claim is the admin
|
|
975
|
+
// "unrestricted" sentinel — minting it for a non-admin grants
|
|
976
|
+
// hub-wide vault access. Force fall-through to the consent
|
|
977
|
+
// render where the zero-vault gate in `handleConsentSubmit` also
|
|
978
|
+
// refuses (defense in depth).
|
|
938
979
|
//
|
|
939
980
|
// The grant is also recorded so subsequent flows with the same scopes
|
|
940
981
|
// hit the standard skip-consent gate above. Logged so an operator
|
|
941
982
|
// auditing "who did this" can trace it back to a same-hub DCR.
|
|
942
983
|
const hasAdminScope = requestedScopes.some(scopeIsAdmin);
|
|
943
|
-
if (
|
|
984
|
+
if (
|
|
985
|
+
client.sameHub &&
|
|
986
|
+
!hasAdminScope &&
|
|
987
|
+
!hasUnnamedVault &&
|
|
988
|
+
!hasStaleAssignment &&
|
|
989
|
+
userHasVaultPosture
|
|
990
|
+
) {
|
|
944
991
|
console.log(
|
|
945
992
|
`[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
|
|
946
993
|
);
|
|
@@ -952,7 +999,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
952
999
|
}
|
|
953
1000
|
|
|
954
1001
|
return htmlResponse(
|
|
955
|
-
renderConsent(
|
|
1002
|
+
renderConsent(
|
|
1003
|
+
consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
|
|
1004
|
+
),
|
|
956
1005
|
200,
|
|
957
1006
|
extra,
|
|
958
1007
|
);
|
|
@@ -1140,18 +1189,46 @@ async function handleConsentSubmit(
|
|
|
1140
1189
|
params.state,
|
|
1141
1190
|
);
|
|
1142
1191
|
}
|
|
1143
|
-
// Multi-user Phase
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
// server-side defense here refuses any mint where the user's
|
|
1148
|
-
// disagrees
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
// as-source-of-truth behavior.
|
|
1192
|
+
// Multi-user Phase 2 PR 2: non-admin users are pinned to a list of one
|
|
1193
|
+
// or more vaults via `user_vaults`. The consent screen renders the
|
|
1194
|
+
// picker narrowed to that list and any named scopes (`vault:<name>:
|
|
1195
|
+
// <verb>`) requested by the client must target a vault in the list.
|
|
1196
|
+
// The server-side defense here refuses any mint where the user's
|
|
1197
|
+
// submission disagrees, so a hand-crafted POST or a misbehaving SPA
|
|
1198
|
+
// can't bypass the narrowing. First admin (admin posture) keeps the
|
|
1199
|
+
// existing picker-as-source-of-truth behavior (empty `assignedVaults`).
|
|
1200
|
+
const userIsAdmin = isFirstAdmin(db, session.userId);
|
|
1153
1201
|
const sessionUser = getUserById(db, session.userId);
|
|
1154
|
-
const
|
|
1202
|
+
const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
|
|
1203
|
+
const isPinned = assignedVaults.length > 0;
|
|
1204
|
+
|
|
1205
|
+
// Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
|
|
1206
|
+
// user with no `user_vaults` rows is a known-but-not-yet-assigned
|
|
1207
|
+
// posture — they can sign in to /account/, change their password, and
|
|
1208
|
+
// see the home page, but they have no vaults to authorize against.
|
|
1209
|
+
// Block any vault-scoped consent at the submit boundary so an OAuth
|
|
1210
|
+
// client can't trick them into minting a token: an empty `vault_scope`
|
|
1211
|
+
// claim is the admin "unrestricted" sentinel (see `vaultScopeForUser`),
|
|
1212
|
+
// and we must keep that sentinel reserved for true admins. Non-vault
|
|
1213
|
+
// scopes (`scribe:transcribe`, etc.) still consent normally — only
|
|
1214
|
+
// `vault:...` scopes are gated here. The defense pairs with the GET-
|
|
1215
|
+
// path same-hub-auto-trust gate below (which falls through to the
|
|
1216
|
+
// consent render that would otherwise show the picker).
|
|
1217
|
+
if (!userIsAdmin && assignedVaults.length === 0) {
|
|
1218
|
+
const submittedScopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
1219
|
+
const hasVaultScope = submittedScopes.some((s) => {
|
|
1220
|
+
if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
|
|
1221
|
+
const parts = s.split(":");
|
|
1222
|
+
return parts.length === 3 && parts[0] === "vault" && parts[2] && VAULT_VERBS.has(parts[2]);
|
|
1223
|
+
});
|
|
1224
|
+
if (hasVaultScope) {
|
|
1225
|
+
return htmlError(
|
|
1226
|
+
"No vaults assigned",
|
|
1227
|
+
"vault_scope_mismatch: you have no assigned vaults on this hub yet, so you can't authorize an app for vault access. Ask the hub admin to assign you at least one vault via /admin/users, then try again.",
|
|
1228
|
+
400,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1155
1232
|
|
|
1156
1233
|
// Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
|
|
1157
1234
|
// `vault:<verb>` scope is ambiguous about which vault it grants access to.
|
|
@@ -1171,24 +1248,17 @@ async function handleConsentSubmit(
|
|
|
1171
1248
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
1172
1249
|
const validNames = listVaultNames(manifest);
|
|
1173
1250
|
if (!validNames.includes(pickedVault)) {
|
|
1174
|
-
// Stale-assignment branch (hub#284).
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
// removed
|
|
1178
|
-
// stale name. The locked picker rendered the missing name, the
|
|
1179
|
-
// form posted it, the user saw "vault X is not registered" and
|
|
1180
|
-
// had no path forward. The new copy names the actual condition
|
|
1181
|
-
// (assignment removed) and points at the admin remediation
|
|
1182
|
-
// surface (/admin/users) instead of leaving the user wondering
|
|
1183
|
-
// whether hub itself is broken.
|
|
1251
|
+
// Stale-assignment branch (hub#284, generalized Phase 2 PR 2). The
|
|
1252
|
+
// user is consenting via an assignment that points at a vault no
|
|
1253
|
+
// longer in services.json. The new copy names the actual condition
|
|
1254
|
+
// (assignment removed) and points at the admin remediation surface.
|
|
1184
1255
|
//
|
|
1185
|
-
// The check fires
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
|
|
1191
|
-
if (assignedVault !== null && pickedVault === assignedVault) {
|
|
1256
|
+
// The check fires when the picked vault is in the user's assigned
|
|
1257
|
+
// list — narrows the special-case to the "user is consenting via
|
|
1258
|
+
// a now-stale assignment" shape rather than swallowing every
|
|
1259
|
+
// Unknown-vault. A hand-crafted POST naming a never-existed vault
|
|
1260
|
+
// still hits the generic branch.
|
|
1261
|
+
if (isPinned && assignedVaults.includes(pickedVault)) {
|
|
1192
1262
|
return htmlError(
|
|
1193
1263
|
"Assigned vault was removed",
|
|
1194
1264
|
`Your assigned vault "${pickedVault}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
|
|
@@ -1201,16 +1271,16 @@ async function handleConsentSubmit(
|
|
|
1201
1271
|
400,
|
|
1202
1272
|
);
|
|
1203
1273
|
}
|
|
1204
|
-
// Server-side defense: non-admin user submitted a vault that
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
// overwriting; the explicit error tells the operator the assignment
|
|
1209
|
-
// load-bearing.
|
|
1210
|
-
if (
|
|
1274
|
+
// Server-side defense: non-admin user submitted a vault that's not in
|
|
1275
|
+
// their assigned list. The picker rendered as narrowed, so a UI-path
|
|
1276
|
+
// user couldn't reach this — but a hand-crafted form bypassing the
|
|
1277
|
+
// narrowed input lands here. Refuse the mint instead of silently
|
|
1278
|
+
// overwriting; the explicit error tells the operator the assignment
|
|
1279
|
+
// is load-bearing.
|
|
1280
|
+
if (isPinned && !assignedVaults.includes(pickedVault)) {
|
|
1211
1281
|
return htmlError(
|
|
1212
1282
|
"Vault assignment mismatch",
|
|
1213
|
-
`vault_scope_mismatch: the picked vault "${pickedVault}"
|
|
1283
|
+
`vault_scope_mismatch: the picked vault "${pickedVault}" is not in your vault assignment. Ask the hub admin to update your assignment, or pick a vault shown on the consent screen.`,
|
|
1214
1284
|
400,
|
|
1215
1285
|
);
|
|
1216
1286
|
}
|
|
@@ -1218,12 +1288,12 @@ async function handleConsentSubmit(
|
|
|
1218
1288
|
}
|
|
1219
1289
|
|
|
1220
1290
|
// Server-side defense for named-vault scopes (`vault:<name>:<verb>`) too.
|
|
1221
|
-
// A non-admin user can't request scope against any vault
|
|
1222
|
-
//
|
|
1223
|
-
// scopes that arrived already-named (e.g. a client that knows the
|
|
1224
|
-
// vault and asked for `vault:bob:read` directly).
|
|
1225
|
-
//
|
|
1226
|
-
if (
|
|
1291
|
+
// A non-admin user can't request scope against any vault outside their
|
|
1292
|
+
// assignment list — same invariant as the picker check above, applied
|
|
1293
|
+
// to scopes that arrived already-named (e.g. a client that knows the
|
|
1294
|
+
// user's vault and asked for `vault:bob:read` directly). Admin posture
|
|
1295
|
+
// (`isPinned === false`) skips this check.
|
|
1296
|
+
if (isPinned) {
|
|
1227
1297
|
const mismatched: string[] = [];
|
|
1228
1298
|
for (const s of scopes) {
|
|
1229
1299
|
const parts = s.split(":");
|
|
@@ -1233,7 +1303,7 @@ async function handleConsentSubmit(
|
|
|
1233
1303
|
parts[1] &&
|
|
1234
1304
|
parts[2] &&
|
|
1235
1305
|
VAULT_VERBS.has(parts[2]) &&
|
|
1236
|
-
parts[1]
|
|
1306
|
+
!assignedVaults.includes(parts[1])
|
|
1237
1307
|
) {
|
|
1238
1308
|
mismatched.push(s);
|
|
1239
1309
|
}
|
|
@@ -1241,29 +1311,29 @@ async function handleConsentSubmit(
|
|
|
1241
1311
|
if (mismatched.length > 0) {
|
|
1242
1312
|
return htmlError(
|
|
1243
1313
|
"Vault assignment mismatch",
|
|
1244
|
-
`vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault
|
|
1314
|
+
`vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault outside your assignment.`,
|
|
1245
1315
|
400,
|
|
1246
1316
|
);
|
|
1247
1317
|
}
|
|
1248
1318
|
|
|
1249
|
-
// Stale-assignment defense (hub#284
|
|
1250
|
-
// shaped `vault:<assigned>:<verb>` passes the mismatch
|
|
1251
|
-
// points at a vault that no longer exists in
|
|
1252
|
-
// token here would silently issue scope
|
|
1253
|
-
// server can't find — the user thinks
|
|
1254
|
-
// subsequent API calls fail with no
|
|
1255
|
-
// and surface the same admin-
|
|
1256
|
-
//
|
|
1257
|
-
// the same recovery story.
|
|
1319
|
+
// Stale-assignment defense (hub#284, generalized Phase 2 PR 2). A
|
|
1320
|
+
// named scope shaped `vault:<assigned>:<verb>` passes the mismatch
|
|
1321
|
+
// check above but points at a vault that no longer exists in
|
|
1322
|
+
// services.json. Minting a token here would silently issue scope
|
|
1323
|
+
// against a vault the resource server can't find — the user thinks
|
|
1324
|
+
// consent succeeded but the subsequent API calls fail with no
|
|
1325
|
+
// actionable signal. Refuse the mint and surface the same admin-
|
|
1326
|
+
// remediation hint the GET path's banner uses.
|
|
1258
1327
|
const namedStaleScopes: string[] = [];
|
|
1259
1328
|
for (const s of scopes) {
|
|
1260
1329
|
const parts = s.split(":");
|
|
1261
1330
|
if (
|
|
1262
1331
|
parts.length === 3 &&
|
|
1263
1332
|
parts[0] === "vault" &&
|
|
1264
|
-
parts[1]
|
|
1333
|
+
parts[1] !== undefined &&
|
|
1265
1334
|
parts[2] &&
|
|
1266
|
-
VAULT_VERBS.has(parts[2])
|
|
1335
|
+
VAULT_VERBS.has(parts[2]) &&
|
|
1336
|
+
assignedVaults.includes(parts[1])
|
|
1267
1337
|
) {
|
|
1268
1338
|
namedStaleScopes.push(s);
|
|
1269
1339
|
}
|
|
@@ -1273,10 +1343,19 @@ async function handleConsentSubmit(
|
|
|
1273
1343
|
// the no-vault-scope hot path off-disk for the common admin flows.
|
|
1274
1344
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
1275
1345
|
const validNames = listVaultNames(manifest);
|
|
1276
|
-
|
|
1346
|
+
// Collect the stale vault names embedded in the named scopes.
|
|
1347
|
+
const staleNames = new Set<string>();
|
|
1348
|
+
for (const s of namedStaleScopes) {
|
|
1349
|
+
const parts = s.split(":");
|
|
1350
|
+
if (parts[1] !== undefined && !validNames.includes(parts[1])) {
|
|
1351
|
+
staleNames.add(parts[1]);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (staleNames.size > 0) {
|
|
1355
|
+
const exemplar = [...staleNames][0];
|
|
1277
1356
|
return htmlError(
|
|
1278
1357
|
"Assigned vault was removed",
|
|
1279
|
-
`Your assigned vault "${
|
|
1358
|
+
`Your assigned vault "${exemplar}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
|
|
1280
1359
|
400,
|
|
1281
1360
|
);
|
|
1282
1361
|
}
|
|
@@ -1421,10 +1500,7 @@ export async function handleApproveClientPost(
|
|
|
1421
1500
|
);
|
|
1422
1501
|
}
|
|
1423
1502
|
target.searchParams.set("error", "access_denied");
|
|
1424
|
-
target.searchParams.set(
|
|
1425
|
-
"error_description",
|
|
1426
|
-
"The user denied the authorization request.",
|
|
1427
|
-
);
|
|
1503
|
+
target.searchParams.set("error_description", "The user denied the authorization request.");
|
|
1428
1504
|
if (denyState !== undefined) target.searchParams.set("state", denyState);
|
|
1429
1505
|
return redirectResponse(target.toString());
|
|
1430
1506
|
}
|
|
@@ -1660,11 +1736,17 @@ async function handleTokenAuthorizationCode(
|
|
|
1660
1736
|
audience,
|
|
1661
1737
|
clientId: redeemed.clientId,
|
|
1662
1738
|
issuer: deps.issuer,
|
|
1663
|
-
// vault_scope claim — Phase
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1739
|
+
// vault_scope claim — Phase 2 per-user vault pin (Phase 1 had a single
|
|
1740
|
+
// `assigned_vault` column; Phase 2 PR 2 generalized to `assigned_vaults`
|
|
1741
|
+
// via `user_vaults`). Non-empty list for non-admin users with at least
|
|
1742
|
+
// one assigned vault; empty for first-admin (unrestricted sentinel).
|
|
1743
|
+
// Zero-vault non-admin is also empty by `vaultScopeForUser`, but the
|
|
1744
|
+
// OAuth flow refuses to mint a vault-scoped token for them upstream
|
|
1745
|
+
// (see the zero-vault gate in `handleConsentSubmit` + the same-hub
|
|
1746
|
+
// auto-trust posture check), so we never reach here with that user
|
|
1747
|
+
// posture. The narrowing in `handleConsentSubmit` already rewrote
|
|
1748
|
+
// `vault:<verb>` → `vault:<assigned>:<verb>`, so the auth code's
|
|
1749
|
+
// scopes are pre-aligned; this claim is the explicit "owned vaults"
|
|
1668
1750
|
// signal PR 5 consumes downstream.
|
|
1669
1751
|
vaultScope: vaultScopeForUser(db, redeemed.userId),
|
|
1670
1752
|
now: deps.now,
|
|
@@ -1781,12 +1863,12 @@ async function handleTokenRefresh(
|
|
|
1781
1863
|
clientId: row.clientId,
|
|
1782
1864
|
issuer: deps.issuer,
|
|
1783
1865
|
// vault_scope claim — re-derived from the user's *current*
|
|
1784
|
-
// `
|
|
1785
|
-
// token row). An admin who changes a user's
|
|
1866
|
+
// `assigned_vaults` at refresh time (not snapshotted onto the refresh-
|
|
1867
|
+
// token row). An admin who changes a user's vault assignments between
|
|
1786
1868
|
// mint and refresh sees the new value on the next refresh; existing
|
|
1787
1869
|
// access tokens carry their original claim until their 15-minute TTL
|
|
1788
1870
|
// elapses. Same posture as the design's "OAuth issuer reads
|
|
1789
|
-
// `
|
|
1871
|
+
// `assigned_vaults` at mint time, not at session-creation time" pin.
|
|
1790
1872
|
vaultScope: vaultScopeForUser(db, refreshUserId),
|
|
1791
1873
|
now: deps.now,
|
|
1792
1874
|
});
|
|
@@ -2156,95 +2238,117 @@ function consentProps(
|
|
|
2156
2238
|
params: AuthorizeFormParams,
|
|
2157
2239
|
vaultNames: string[],
|
|
2158
2240
|
csrfToken: string,
|
|
2159
|
-
|
|
2241
|
+
assignedVaults: readonly string[],
|
|
2242
|
+
userIsAdmin: boolean,
|
|
2160
2243
|
) {
|
|
2161
2244
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
2162
2245
|
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
2163
|
-
// Multi-user Phase
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
// "Unknown vault" 400. The user couldn't tell what happened or how to
|
|
2169
|
-
// recover.
|
|
2246
|
+
// Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
|
|
2247
|
+
// from one vault to N). A non-admin user whose entire vault list has
|
|
2248
|
+
// been removed from services.json — admin removed / renamed the vaults
|
|
2249
|
+
// without reassigning. The banner surfaces this state with an admin-
|
|
2250
|
+
// remediation hint instead of silently minting against a missing vault.
|
|
2170
2251
|
//
|
|
2171
|
-
//
|
|
2172
|
-
// the
|
|
2173
|
-
//
|
|
2174
|
-
//
|
|
2175
|
-
//
|
|
2176
|
-
// depends on a vault — see the `vaultPicker` + `approveDisabled` logic
|
|
2177
|
-
// below.
|
|
2252
|
+
// The user-facing surface is "your assigned vault(s) were removed" —
|
|
2253
|
+
// the banner names the first stale vault as the canonical example. The
|
|
2254
|
+
// exact list is unimportant for the recovery path (operator goes to
|
|
2255
|
+
// /admin/users either way), and pluralizing the banner copy doesn't
|
|
2256
|
+
// change the action.
|
|
2178
2257
|
//
|
|
2179
|
-
// Security posture: we deliberately do NOT relax the picked-must-
|
|
2180
|
-
// assigned check
|
|
2181
|
-
|
|
2182
|
-
|
|
2258
|
+
// Security posture: we deliberately do NOT relax the picked-must-be-in-
|
|
2259
|
+
// assigned-list check. Stale-assignment is admin-remediated.
|
|
2260
|
+
const remainingValidAssigned = assignedVaults.filter((v) => vaultNames.includes(v));
|
|
2261
|
+
const hasStaleAssignment = assignedVaults.length > 0 && remainingValidAssigned.length === 0;
|
|
2183
2262
|
const staleAssignedVault =
|
|
2184
|
-
|
|
2185
|
-
// A named scope like `vault:<old
|
|
2186
|
-
// <old> is the user's stale
|
|
2187
|
-
// defense
|
|
2188
|
-
//
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2263
|
+
hasStaleAssignment && assignedVaults[0] !== undefined ? assignedVaults[0] : undefined;
|
|
2264
|
+
// A named scope like `vault:<old>:<verb>` requested by the client where
|
|
2265
|
+
// <old> is one of the user's stale vaults. The server-side named-scope
|
|
2266
|
+
// defense allows this through because the scope matches an assigned
|
|
2267
|
+
// vault, but the token it mints would point at a vault that no longer
|
|
2268
|
+
// exists. Gate Approve on this case too so the user doesn't burn a
|
|
2269
|
+
// consent into a token that fails at the resource server.
|
|
2191
2270
|
const hasNamedStaleVaultScope =
|
|
2192
|
-
|
|
2271
|
+
hasStaleAssignment &&
|
|
2193
2272
|
scopes.some((s) => {
|
|
2194
2273
|
const parts = s.split(":");
|
|
2195
|
-
|
|
2196
|
-
parts.length
|
|
2197
|
-
parts[0]
|
|
2198
|
-
parts[1] ===
|
|
2199
|
-
parts[2]
|
|
2200
|
-
VAULT_VERBS.has(parts[2])
|
|
2201
|
-
)
|
|
2274
|
+
if (
|
|
2275
|
+
parts.length !== 3 ||
|
|
2276
|
+
parts[0] !== "vault" ||
|
|
2277
|
+
parts[1] === undefined ||
|
|
2278
|
+
parts[2] === undefined ||
|
|
2279
|
+
!VAULT_VERBS.has(parts[2])
|
|
2280
|
+
) {
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
2283
|
+
// Named for one of the user's vaults — and given hasStaleAssignment,
|
|
2284
|
+
// none of the user's vaults exist on this hub, so this scope points
|
|
2285
|
+
// at a stale name.
|
|
2286
|
+
return assignedVaults.includes(parts[1]);
|
|
2202
2287
|
});
|
|
2203
2288
|
|
|
2204
|
-
// Multi-user Phase
|
|
2205
|
-
//
|
|
2206
|
-
// non-null) see the picker locked to their `assigned_vault` rather than a
|
|
2207
|
-
// free dropdown. Phase 2 will hide other vaults entirely; Phase 1 ships
|
|
2208
|
-
// lock-the-picker (the smallest diff that satisfies "user can't pick a
|
|
2209
|
-
// vault they don't own"). Server-side defense in `handleConsentSubmit`
|
|
2210
|
-
// refuses mints whose POST disagrees regardless of how the picker is
|
|
2211
|
-
// rendered.
|
|
2289
|
+
// Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
|
|
2290
|
+
// their assigned vault list. Four shapes emerge:
|
|
2212
2291
|
//
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2292
|
+
// - Single assigned vault (still valid) → render locked to that name
|
|
2293
|
+
// (same shape as Phase 1).
|
|
2294
|
+
// - Two-or-more assigned vaults → render a dropdown filtered to the
|
|
2295
|
+
// user's list. Same control as the admin dropdown but narrowed.
|
|
2296
|
+
// - Stale-assigned (all vaults gone) → render no-vaults-available so
|
|
2297
|
+
// the form gracefully rejects an Approve click instead of silently
|
|
2298
|
+
// submitting a missing name.
|
|
2299
|
+
// - First admin (admin posture, empty `assignedVaults`) → full hub-
|
|
2300
|
+
// wide dropdown of every vault on the hub.
|
|
2301
|
+
// - Zero-vault non-admin (Phase 2 PR 2 reviewer fold) → no-vaults-
|
|
2302
|
+
// available so the form gracefully rejects an Approve click. The
|
|
2303
|
+
// prior shape rendered the full hub-wide list for non-admins with
|
|
2304
|
+
// zero assignments, which let them pick a vault they had no
|
|
2305
|
+
// business consenting to. The consent-submit gate refuses any
|
|
2306
|
+
// vault-scoped POST from a zero-vault non-admin (defense in depth);
|
|
2307
|
+
// this branch keeps the picker UI honest.
|
|
2218
2308
|
let vaultPicker: VaultPickerProps | undefined;
|
|
2219
2309
|
if (unnamedVerbs.length > 0) {
|
|
2220
|
-
if (
|
|
2310
|
+
if (hasStaleAssignment) {
|
|
2221
2311
|
vaultPicker = { unnamedVerbs, availableVaults: [] };
|
|
2222
|
-
} else if (
|
|
2223
|
-
|
|
2224
|
-
|
|
2312
|
+
} else if (remainingValidAssigned.length === 1) {
|
|
2313
|
+
const only = remainingValidAssigned[0];
|
|
2314
|
+
if (only !== undefined) {
|
|
2315
|
+
vaultPicker = { unnamedVerbs, availableVaults: [only], lockedVault: only };
|
|
2316
|
+
}
|
|
2317
|
+
} else if (remainingValidAssigned.length > 1) {
|
|
2318
|
+
vaultPicker = { unnamedVerbs, availableVaults: remainingValidAssigned };
|
|
2319
|
+
} else if (userIsAdmin) {
|
|
2320
|
+
// Admin posture (no assignments) → full hub-wide list.
|
|
2225
2321
|
vaultPicker = { unnamedVerbs, availableVaults: vaultNames };
|
|
2322
|
+
} else {
|
|
2323
|
+
// Zero-vault non-admin → no-vaults-available picker with the
|
|
2324
|
+
// "ask your admin to assign you" copy. The Approve button renders
|
|
2325
|
+
// disabled (same shape as the empty-services-json case) so the
|
|
2326
|
+
// form can't post a hand-picked name. The consent-submit gate
|
|
2327
|
+
// refuses any vault-scoped POST from this user too (defense in
|
|
2328
|
+
// depth — see `handleConsentSubmit`).
|
|
2329
|
+
vaultPicker = { unnamedVerbs, availableVaults: [], emptyReason: "no-assignments" };
|
|
2226
2330
|
}
|
|
2227
2331
|
}
|
|
2228
2332
|
// Named-scope display: substitute unnamed `vault:<verb>` rows with the
|
|
2229
2333
|
// resolved form the operator will actually consent to.
|
|
2230
|
-
// - Non-admin
|
|
2231
|
-
// - Stale-assigned
|
|
2232
|
-
//
|
|
2233
|
-
// -
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
// - Admin with multiple
|
|
2237
|
-
// a `<TBD>` placeholder + a hint pointing at the picker. Once the
|
|
2238
|
-
// operator clicks a radio the JS-free form still posts the chosen
|
|
2239
|
-
// name; the displayed scope only changes after the next page render.
|
|
2334
|
+
// - Non-admin with exactly one valid assigned vault → render that name.
|
|
2335
|
+
// - Stale-assigned → null; the row carries the `<TBD>` placeholder.
|
|
2336
|
+
// The banner explains why a name isn't bound.
|
|
2337
|
+
// - Non-admin with multiple assigned vaults → null sentinel (the user
|
|
2338
|
+
// hasn't picked yet).
|
|
2339
|
+
// - Admin with exactly one vault available → render that name.
|
|
2340
|
+
// - Admin with multiple / no vaults → null sentinel.
|
|
2240
2341
|
let displayVault: string | null = null;
|
|
2241
|
-
if (
|
|
2242
|
-
|
|
2342
|
+
if (!hasStaleAssignment && remainingValidAssigned.length === 1) {
|
|
2343
|
+
const only = remainingValidAssigned[0];
|
|
2344
|
+
if (only !== undefined) displayVault = only;
|
|
2243
2345
|
} else if (
|
|
2244
|
-
|
|
2346
|
+
!hasStaleAssignment &&
|
|
2347
|
+
assignedVaults.length === 0 &&
|
|
2245
2348
|
unnamedVerbs.length > 0 &&
|
|
2246
2349
|
vaultNames.length === 1
|
|
2247
2350
|
) {
|
|
2351
|
+
// Admin with a single vault on the hub: pre-check pattern from Phase 1.
|
|
2248
2352
|
const only = vaultNames[0];
|
|
2249
2353
|
if (only) displayVault = only;
|
|
2250
2354
|
}
|
|
@@ -2271,4 +2375,5 @@ interface VaultPickerProps {
|
|
|
2271
2375
|
unnamedVerbs: string[];
|
|
2272
2376
|
availableVaults: string[];
|
|
2273
2377
|
lockedVault?: string;
|
|
2378
|
+
emptyReason?: "no-assignments" | "no-vaults-on-hub";
|
|
2274
2379
|
}
|