v2.2.0 — per-app authz reshape + standalone→central migration#27
Merged
Conversation
…per-app
Remove the v1 global-roles token fallback for NAMED apps (it leaked a user's
global roles into every app's token) while preserving exact v1 behavior for the
default ("home") app. resolveTokenRoles now splits:
- home app -> the v1 global world: roles from the per-user global store,
perms from the global role->permission catalog + direct perms, default_roles
as the baseline for unassigned users, require_assignment gating on an
explicit global role.
- named app -> strictly per-app: roles/perms only from that app's AppAuthz;
global roles/permissions never appear in a named-app token.
Per-user roles stay in the independent, atomic, per-GUID global store (as on
master), not in any AppAuthz blob, so there is no shared-document clobber surface
and a v1 store needs no data migration — it works on a binary swap.
Hardening:
- the default ("home") app is first-class: reserved id, undeletable, and
cannot enable local users (would be dead config).
- the per-app authz surfaces (self-service GET/PUT/bootstrap + admin GET/PUT)
refuse the default app; its authz is not its token source.
- drop now-dead assignDefaultRoles/resolveUserPermissions and 7 call sites;
default_roles is applied live at token issuance.
Adversarially reviewed in two passes; closes a self-service role-wipe, an
authz-blob clobber/lost-update, a directory role-table disclosure, and a
require_assignment over-grant that an earlier AppAuthz-storage approach had.
Tests: internal/handler/homeapp_test.go covers home-app inheritance, named-app
no-leak, require_assignment semantics, and every guard. Full suite + -race green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Engine + endpoints to migrate a standalone SimpleAuth deployment into a single
named app on a central install, carrying the AUTHORIZATION POLICY (not signing
keys or avoidable PII):
- internal/migrate: Bundle + Package (source) + Classify (dry-run) + Apply
(commit). Users split by AUTH METHOD — AD users travel keyed by sAMAccountName
(re-bound from the central's AD on login; no record/password copied); local
users carry their password hash and become app-local users on the target app.
App config (audience, redirect_uris, cors, secret hash) is carried so the
consumer app's existing client_id/secret/redirect_uri keep working; only the
issuer/JWKS change (handled by OIDC discovery).
- Central endpoints (token-authed): a master mints a single-use, expiring,
app-scoped migration token on the target app; /api/migration/preflight returns
the dry-run report; /api/migration/commit applies it, refuses if any user is
blocked, and consumes the token.
- Standalone endpoints (master): /api/admin/migrate-to-central/{preflight,commit}
package this deployment and call the central over TLS-verified HTTP.
The default app is reserved as a migration target. Tests: engine (same-AD /
no-AD / different-AD), central flow (token single-use, blocked-AD refusal), and a
cross-install end-to-end over real HTTP. Full suite + -race green.
UI + adversarial review + the 2.2.0 bump follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Apps page: a "Migrate token" action per app mints a single-use migration token (shown once) for a standalone deployment migrating into that app. - New "Migrate" page (standalone side): central URL + target app + token, a preflight dry-run showing the user split / blocked users / redirect URIs to review / notes, and a guarded Commit (only when nothing is blocked) with a carry-secret toggle. - docs/MIGRATION-STANDALONE-TO-CENTRAL.md. - VERSION -> 2.2.0. UI verified via headless screenshots (Migrate page + Apps token button). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A read-only adversarial review found 7 real defects; all fixed: - Token single-use is now race-free: handleMigrationCommit holds migrationMu across guard + claim + Apply, and the token is consumed BEFORE Apply (a partial, non-transactional Apply leaves no replayable token — mint a fresh one to retry). Apply's app-local provisioning runs under localUserMu so it cannot race a self-service create. [TOCTOU + consume-after-Apply] - Transport: the standalone refuses cleartext http:// to non-loopback hosts (the bundle carries local-user password hashes + the app secret hash), and the outbound client no longer follows redirects (no https->http downgrade / host steer). [cleartext + SSRF-via-redirect] - Apply OR-ins require_assignment instead of overwriting it, so a migration can never downgrade a locked-down target app to open. [gate downgrade] - The migration token is bound to the app INSTANCE (CreatedAt) and invalidated on app delete + secret rotation; commit/preflight reject a disabled target — an orphaned token can no longer take over a recreated app_id. [token lifecycle] - Classify refuses a target that already has per-app authz (fresh-target guard), so a migration cannot wholesale-clobber an in-use app. [clobber] Tests: no-downgrade, fresh-target, https-required, token-invalidated-on-delete, disabled-target. Full suite + -race green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A read-only confirming review verified the first 7 fixes (F1-F5,F7 resolved) and caught the rest: - Fresh-target clobber guard ignored GroupAssignments/Permissions, so a group-authorized in-use app was still clobberable (this was F6, not fully fixed) — Classify now blocks on ANY existing authz (user/group assignments, roles, role->perm map, or permission catalog). - migrate.Apply ran under a bare localUserMu.Lock()/Unlock(); a panic inside Apply would permanently leak the lock and deadlock all local-user provisioning — moved to a defer in a closure. - consumeMigrationToken silently swallowed store errors, leaving a "claimed" token actually replayable — it now returns an error and commit fails closed (token unspent, retryable) if the claim can't persist. - guardMigrationCall reordered so app existence/disabled were revealed BEFORE token auth (an unauthenticated app_id enumeration oracle on the public preflight/commit endpoints) — auth now runs first; missing-app and bad-token return an identical 401, and "disabled" is reported only to a valid token. - Commit failure message now guides recovery of a partial (non-transactional) Apply. Tests: group-target clobber guard added; disabled-target still 403 (after valid auth). Full suite + -race green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v2.2.0
Two features, each built and adversarially reviewed (two passes apiece, both converged).
1. Per-app authz reshape
The default ("home") app is the v1 global world; named apps are strictly per-app — a user's global roles can no longer leak into a named app's token. Per-user roles stay in the independent global store (no migration needed). The default app is reserved/undeletable; the per-app authz surfaces refuse it.
2. Standalone → central migration
Adopt a standalone SimpleAuth deployment as a single named app on a central install, carrying the authorization policy (not signing keys). Users split by auth method: AD users travel keyed by sAMAccountName (re-bound from the same AD), local users carry their password hash → app-local users. Single-use, app-instance-bound migration token; dry-run preflight that blocks unsatisfiable users; commit refuses on blocked users / non-fresh targets. Admin UI (Migrate page + per-app token button) + docs.
Verification
go test -race ./...greenApplyfailure requires clearing the target before retry🤖 Generated with Claude Code