Skip to content

v2.2.0 — per-app authz reshape + standalone→central migration#27

Merged
bodaay merged 5 commits into
masterfrom
feat/per-app-roles
Jun 4, 2026
Merged

v2.2.0 — per-app authz reshape + standalone→central migration#27
bodaay merged 5 commits into
masterfrom
feat/per-app-roles

Conversation

@bodaay

@bodaay bodaay commented Jun 4, 2026

Copy link
Copy Markdown
Owner

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

  • Full suite + go test -race ./... green
  • 4 adversarial review passes total; all confirmed findings fixed (token TOCTOU/single-use, cleartext transport, require_assignment downgrade, token lifecycle, clobber/fresh-target incl. group assignments, lock-deadlock, app-existence oracle)
  • One documented edge: a partial (non-transactional) Apply failure requires clearing the target before retry

🤖 Generated with Claude Code

bodaay and others added 5 commits June 2, 2026 21:57
…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>
@bodaay bodaay merged commit bdb9233 into master Jun 4, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant