diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/docs/MIGRATION-STANDALONE-TO-CENTRAL.md b/docs/MIGRATION-STANDALONE-TO-CENTRAL.md new file mode 100644 index 0000000..1d29cd8 --- /dev/null +++ b/docs/MIGRATION-STANDALONE-TO-CENTRAL.md @@ -0,0 +1,67 @@ +# Migrating a standalone deployment into a central SimpleAuth (v2.2) + +A standalone SimpleAuth deployment is, in effect, *one app plus its directory*. +This feature adopts that standalone as a **single named app** on a central +SimpleAuth — carrying the authorization **policy**, not signing keys or avoidable +PII. + +## What moves + +| Moves | Doesn't move | +|---|---| +| home app config: audience, redirect_uris, cors, secret hash | signing keys (the central signs with its own) | +| role catalog + role→permission map → the target app's authz | user PII for AD users (the central re-binds them from AD) | +| each user's **effective** roles, keyed by a portable identity | passwords for AD users (none are stored) | +| local users' password hash → app-local users on the target | LDAP bind credentials | + +Users are split by **how they authenticate**, because that's what doesn't move: + +- **AD users** travel keyed by `sAMAccountName`. The central must be on the **same + AD**; it re-binds the same person on their next login and resolves the carried + assignment. No record or password is copied. +- **Local users** carry their **password hash** and become **app-local users** on + the target app — same username, same password, no change for the user. + +## Steps + +1. **On the central:** create the target app (`Apps → New App`), then click + **Migrate token** on that app to mint a single-use, expiring token. +2. **On the standalone:** open **Migrate**, enter the central URL + target app id + + token, and click **Preflight (dry run)**. Nothing changes yet. +3. **Review the dry-run report:** the user split (AD same-domain / local / blocked), + the redirect URIs that will be allowlisted on the central, and any notes. + Commit is refused while any user is **blocked**. +4. **Commit.** The standalone packages its directory + policy and pushes it to the + central over TLS-verified HTTP; the token is consumed. +5. **Point your consumer apps at the central's URL.** With *carry secret* on, their + `client_id`, `audience`, `redirect_uri`, and secret are unchanged — only the + issuer/JWKS change, handled automatically by OIDC discovery. + +## When the central isn't on the same AD + +The pre-flight **blocks** AD users the central can't authenticate and tells you why: + +- **Central not on AD** → connect it to the same AD first. +- **Central on a different AD** → key by UPN/email or connect the same AD + *(planned for a later release)*. + +Local users are always satisfiable (their hash travels), so a standalone with only +local accounts migrates to *any* central. + +## Security + +- Importing a directory is a master-level operation, so it is gated by a + **single-use, app-scoped migration token** a master admin mints on the target app + — not by the bare app secret. Tokens are stored hashed, expire, and are consumed + on commit. +- The standalone sends its directory (incl. local-user password hashes) over the + operator-supplied URL with **TLS verification on**. Confirm you're pointing at the + real central. +- The default ("home") app cannot be a migration target. +- Commit re-runs the classifier server-side and refuses if any user is blocked. + +## Not carried in v2.2 (re-grant on the target if needed) + +Direct per-user permissions (named apps derive permissions from roles); per-app +group→role mapping; different-AD identity reconciliation; the reverse direction +(central → standalone). diff --git a/internal/handler/admin.go b/internal/handler/admin.go index e5d8224..dffee8a 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -463,6 +463,8 @@ func (h *Handler) handleResolveMapping(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetRoles(w http.ResponseWriter, r *http.Request) { guid := pathParam(r, "guid") + // Per-user global roles = the user's roles in the default ("home") app (v2). + // Named apps manage their own assignments via the per-app authz editor. roles, err := h.store.GetUserRoles(guid) if err != nil { jsonError(w, "failed to get roles", http.StatusInternalServerError) @@ -1036,7 +1038,7 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { } } - // Set roles + // Set roles (the user's home-app roles, in the global per-user store) if u.Roles != nil { if err := h.store.SetUserRoles(guid, u.Roles); err != nil { jsonError(w, fmt.Sprintf("failed to set roles for %s: %v", u.Username, err), http.StatusBadRequest) diff --git a/internal/handler/admin_apps.go b/internal/handler/admin_apps.go index a3c7891..64f915c 100644 --- a/internal/handler/admin_apps.go +++ b/internal/handler/admin_apps.go @@ -77,6 +77,13 @@ func (h *Handler) handleCreateApp(w http.ResponseWriter, r *http.Request) { jsonError(w, "app_id must be 1-64 chars: lowercase letters, digits, '-' or '_' (or provide a name)", http.StatusBadRequest) return } + // The default app's id is reserved: a named app with that id would be routed + // through the home-app branch of resolveTokenRoles (its per-app authz ignored, + // global roles leaked). The default app is created only by ensureDefaultApp. + if appID == h.defaultAppID() { + jsonError(w, "app_id is reserved for the default app", http.StatusBadRequest) + return + } audience := strings.TrimSpace(req.Audience) if audience == "" { audience = appID @@ -180,6 +187,13 @@ func (h *Handler) handleUpdateApp(w http.ResponseWriter, r *http.Request) { a.RequireAssignment = *req.RequireAssignment } if req.AllowLocalUsers != nil { + // The default app is the global directory; "app-local users" there would get + // role assignments written into its AppAuthz, which the home-app token path + // ignores (dead config). Directory users are created via /api/admin/users. + if *req.AllowLocalUsers && a.AppID == h.defaultAppID() { + jsonError(w, "the default app cannot enable local users (it is the global directory)", http.StatusBadRequest) + return + } a.AllowLocalUsers = *req.AllowLocalUsers } if req.Disabled != nil { @@ -196,10 +210,19 @@ func (h *Handler) handleUpdateApp(w http.ResponseWriter, r *http.Request) { // handleDeleteApp removes an app. DELETE /api/admin/apps/{app_id} func (h *Handler) handleDeleteApp(w http.ResponseWriter, r *http.Request) { appID := pathParam(r, "app_id") + // The default ("home") app is first-class and undeletable: it is the global + // directory and the target of every app-less token request. Deleting it would + // also free its id for re-registration as a named app (which resolveTokenRoles + // would then route through the home-app branch — leaking global roles). + if appID == h.defaultAppID() { + jsonError(w, "the default app cannot be deleted", http.StatusForbidden) + return + } if err := h.store.DeleteApp(appID); err != nil { jsonError(w, "failed to delete app", http.StatusInternalServerError) return } + h.store.DeleteConfigValue(migrationTokenKey(appID)) // don't leave a token that could re-target a recreated id h.audit("app_deleted", "admin", getClientIP(r), map[string]interface{}{"app_id": appID}) jsonResp(w, map[string]string{"status": "deleted"}, http.StatusOK) } @@ -228,6 +251,7 @@ func (h *Handler) handleRotateAppSecret(w http.ResponseWriter, r *http.Request) jsonError(w, "failed to rotate secret", http.StatusInternalServerError) return } + h.store.DeleteConfigValue(migrationTokenKey(a.AppID)) // rotating credentials invalidates a pending migration token h.audit("app_secret_rotated", "admin", getClientIP(r), map[string]interface{}{"app_id": a.AppID}) jsonResp(w, map[string]interface{}{"app_id": a.AppID, "app_secret": secret}, http.StatusOK) } @@ -237,6 +261,11 @@ func (h *Handler) handleRotateAppSecret(w http.ResponseWriter, r *http.Request) // GET /api/admin/apps/{app_id}/authz func (h *Handler) handleGetAppAuthz(w http.ResponseWriter, r *http.Request) { appID := pathParam(r, "app_id") + // The default app's authz is not its token source (the home app reads global + // roles); refuse the per-app authz surface for it, matching the PUT guard. + if h.defaultAppAuthzForbidden(w, appID) { + return + } if _, err := h.store.GetApp(appID); err != nil { jsonError(w, "app not found", http.StatusNotFound) return @@ -253,6 +282,12 @@ func (h *Handler) handleGetAppAuthz(w http.ResponseWriter, r *http.Request) { // PUT /api/admin/apps/{app_id}/authz func (h *Handler) handleSetAppAuthz(w http.ResponseWriter, r *http.Request) { appID := pathParam(r, "app_id") + // The default ("home") app's roles live in the global per-user store, not its + // AppAuthz (resolveTokenRoles ignores the home app's AppAuthz). Writing it here + // would be dead config, so refuse it and point to the right surface. + if h.defaultAppAuthzForbidden(w, appID) { + return + } if _, err := h.store.GetApp(appID); err != nil { jsonError(w, "app not found", http.StatusNotFound) return diff --git a/internal/handler/admin_ldap.go b/internal/handler/admin_ldap.go index ef2a83d..80de2ad 100644 --- a/internal/handler/admin_ldap.go +++ b/internal/handler/admin_ldap.go @@ -497,9 +497,6 @@ func (h *Handler) handleImportLDAPUsers(w http.ResponseWriter, r *http.Request) // Create LDAP identity mapping h.store.SetIdentityMapping("ldap", username, user.GUID) - // Assign default roles - h.assignDefaultRoles(user.GUID) - h.audit("ldap_user_imported", user.GUID, getClientIP(r), map[string]interface{}{ "username": username, "display_name": ldapUser.DisplayName, }) diff --git a/internal/handler/app_selfservice.go b/internal/handler/app_selfservice.go index 23e81ab..de975e0 100644 --- a/internal/handler/app_selfservice.go +++ b/internal/handler/app_selfservice.go @@ -120,9 +120,28 @@ func (h *Handler) handleAppToken(w http.ResponseWriter, r *http.Request) { }, http.StatusOK) } +// defaultAppAuthzForbidden writes a 403 and returns true when appID is the +// default ("home") app. The home app's authorization is the v1 global world — +// managed via the Roles & Permissions catalog + per-user roles, NOT the per-app +// authz surface (resolveTokenRoles ignores the home app's AppAuthz). Refusing +// these writes/reads keeps a holder of the legacy client secret (or a home-app +// admin) from clobbering or reading the directory's home-app role data through a +// surface that has no effect anyway. +func (h *Handler) defaultAppAuthzForbidden(w http.ResponseWriter, appID string) bool { + if appID == h.defaultAppID() { + jsonError(w, "the default app is managed via Roles & Permissions and per-user roles, not the per-app authz surface", http.StatusForbidden) + return true + } + return false +} + // handleGetOwnAuthz returns the calling app's authorization. GET /api/app/authz func (h *Handler) handleGetOwnAuthz(w http.ResponseWriter, r *http.Request) { - authz, err := h.store.GetAppAuthz(appIDFromContext(r)) + appID := appIDFromContext(r) + if h.defaultAppAuthzForbidden(w, appID) { + return + } + authz, err := h.store.GetAppAuthz(appID) if err != nil { jsonError(w, "failed to read authz", http.StatusInternalServerError) return @@ -133,6 +152,9 @@ func (h *Handler) handleGetOwnAuthz(w http.ResponseWriter, r *http.Request) { // handleSetOwnAuthz replaces the calling app's authorization. PUT /api/app/authz func (h *Handler) handleSetOwnAuthz(w http.ResponseWriter, r *http.Request) { appID := appIDFromContext(r) + if h.defaultAppAuthzForbidden(w, appID) { + return + } var authz store.AppAuthz if err := readJSON(r, &authz); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) @@ -152,6 +174,9 @@ func (h *Handler) handleSetOwnAuthz(w http.ResponseWriter, r *http.Request) { // on every deploy. POST /api/app/bootstrap func (h *Handler) handleAppBootstrap(w http.ResponseWriter, r *http.Request) { appID := appIDFromContext(r) + if h.defaultAppAuthzForbidden(w, appID) { + return + } var req struct { Roles []string `json:"roles"` Permissions []string `json:"permissions"` diff --git a/internal/handler/apps.go b/internal/handler/apps.go index 25ea0bb..3a79634 100644 --- a/internal/handler/apps.go +++ b/internal/handler/apps.go @@ -85,15 +85,34 @@ func (h *Handler) userAssignmentKeys(user *store.User) []string { } // resolveTokenRoles computes the roles + permissions a token for `app` should -// carry for `user` (v2 M3). When the app has no per-app authorization configured -// it falls back to the global (v1) roles, so existing deployments keep working -// until they define per-app authz. `denied` is true when the app has -// require_assignment set and the user has no direct or group assignment. +// carry for `user` (v2). +// +// Two distinct authorization worlds, split on the app: +// +// - The default ("home") app IS the v1 global world. Its roles are the user's +// global per-user roles (GetUserRoles); an unassigned user falls back to the +// global default_roles baseline; permissions resolve from the global +// role→permission catalog plus the user's direct permissions. This is exactly +// v1 behavior — but it now applies ONLY to the home app. +// - A NAMED app is strictly per-app: roles come only from that app's AppAuthz +// (direct user assignment + AD-group assignment) and permissions only from its +// own role→permission map. Global per-user roles/permissions NEVER leak into a +// named app's token (the v1 global-roles fallback is gone for named apps). +// +// Keeping the home app's per-user roles in the independent global role store +// (rather than the shared, whole-document-replaced AppAuthz blob) is deliberate: +// it keeps that data atomic per-user and out of reach of the self-service authz +// surface — the same isolation the codebase applies to AppAdmin. +// +// `denied` is true when require_assignment is set and the user has no qualifying +// assignment (a global role for the home app; a per-app assignment for a named +// app), so the app fails CLOSED rather than admitting users with no grant. func (h *Handler) resolveTokenRoles(app *store.App, user *store.User) (roles, perms []string, denied bool) { - authz, _ := h.store.GetAppAuthz(app.AppID) - hasPerApp := authz != nil && (len(authz.Roles) > 0 || len(authz.UserAssignments) > 0 || len(authz.GroupAssignments) > 0) + if app.AppID == h.defaultAppID() { + return h.resolveHomeAppRoles(app, user) + } - // Compute this user's per-app assignment (direct + group), if any authz exists. + authz, _ := h.store.GetAppAuthz(app.AppID) roleSet := map[string]struct{}{} assigned := false if authz != nil { @@ -111,31 +130,63 @@ func (h *Handler) resolveTokenRoles(app *store.App, user *store.User) (roles, pe } } - // require_assignment denies directory users with no per-app assignment. This - // is evaluated BEFORE the v1 global-roles fallback (H6): an app that flips the - // flag on but hasn't populated assignments must fail CLOSED, not admit every - // directory user with their global roles. App-local users owned by this app - // are inherently the app's and exempt (M5). + // require_assignment denies directory users with no per-app assignment (H6). + // App-local users owned by this app are inherently the app's and exempt (M5). if app.RequireAssignment && !assigned && user.OwnerAppID != app.AppID { return nil, nil, true } - // v1 back-compat: when the app hasn't defined its own authz AND doesn't - // require assignment, carry the global roles/permissions exactly as v1 did so - // existing single-app deployments keep working until they define per-app authz. - if !hasPerApp && !app.RequireAssignment { - gRoles, _ := h.store.GetUserRoles(user.GUID) - return gRoles, h.resolveUserPermissions(user.GUID, gRoles), false + roles = sortedKeys(roleSet) + permSet := map[string]struct{}{} + if authz != nil { + for _, r := range roles { + for _, p := range authz.RolePermissions[r] { + permSet[p] = struct{}{} + } + } } + perms = sortedKeys(permSet) + return roles, perms, false +} + +// resolveHomeAppRoles resolves roles+perms for the default ("home") app from the +// global v1 stores: per-user roles, the role→permission catalog, direct perms, +// and the default_roles baseline. See resolveTokenRoles for the rationale. +func (h *Handler) resolveHomeAppRoles(app *store.App, user *store.User) (roles, perms []string, denied bool) { + gRoles, _ := h.store.GetUserRoles(user.GUID) + assigned := len(gRoles) > 0 + // require_assignment on the home app means "explicit role grant required": an + // admin must have assigned the user a global role. The default_roles baseline + // does NOT satisfy it, so the app still fails CLOSED for users with no grant. + if app.RequireAssignment && !assigned && user.OwnerAppID != app.AppID { + return nil, nil, true + } + + roleSet := map[string]struct{}{} + for _, r := range gRoles { + roleSet[r] = struct{}{} + } + if !assigned { + if def, _ := h.store.GetDefaultRoles(); len(def) > 0 { + for _, r := range def { + roleSet[r] = struct{}{} + } + } + } roles = sortedKeys(roleSet) permSet := map[string]struct{}{} + global, _ := h.store.GetRolePermissions() for _, r := range roles { - for _, p := range authz.RolePermissions[r] { + for _, p := range global[r] { permSet[p] = struct{}{} } } + directPerms, _ := h.store.GetUserPermissions(user.GUID) + for _, p := range directPerms { + permSet[p] = struct{}{} + } perms = sortedKeys(permSet) return roles, perms, false } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 84bcbb2..b512989 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -86,9 +86,6 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { return } - // Assign default roles if user has none - h.assignDefaultRoles(finalUser.GUID) - log.Printf("[login] Success user=%q guid=%s name=%q ip=%s", req.Username, finalUser.GUID, finalUser.DisplayName, ip) // Check force password change — still issue tokens but flag the response @@ -436,19 +433,6 @@ func (h *Handler) issueTokenPair(user *store.User, roles []string, perms []strin return accessToken, refreshToken, int(h.cfg.AccessTTL.Seconds()), nil } -// assignDefaultRoles assigns default roles if the user has no roles yet. -func (h *Handler) assignDefaultRoles(userGUID string) { - existingRoles, _ := h.store.GetUserRoles(userGUID) - if len(existingRoles) > 0 { - return - } - - defaults, _ := h.store.GetDefaultRoles() - if len(defaults) > 0 { - h.store.SetUserRoles(userGUID, defaults) - } -} - // resolvePreferredUsername finds the username for a user from identity mappings. // Priority: local mapping > ldap mapping > email > display name. func (h *Handler) resolvePreferredUsername(user *store.User) string { @@ -472,13 +456,6 @@ func (h *Handler) resolvePreferredUsername(user *store.User) string { return user.DisplayName } -// resolveUserPermissions returns the merged set of role-derived + direct permissions. -func (h *Handler) resolveUserPermissions(userGUID string, roles []string) []string { - directPerms, _ := h.store.GetUserPermissions(userGUID) - merged, _ := h.store.ResolvePermissions(roles, directPerms) - return merged -} - func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { ip := getClientIP(r) if !h.loginLimiter.allow(ip) { @@ -964,9 +941,6 @@ func (h *Handler) handleNegotiate(w http.ResponseWriter, r *http.Request) { return } - // Assign default roles if user has none - h.assignDefaultRoles(user.GUID) - // Resolve the app (v2) — optional client_id query, else default app. app, err := h.resolveApp(r.URL.Query().Get("client_id")) if err != nil { @@ -1241,8 +1215,6 @@ func (h *Handler) handleSSOLogin(w http.ResponseWriter, r *http.Request) { return } - h.assignDefaultRoles(user.GUID) - // app was resolved + redirect validated at the top of the handler (L1). roles, perms, denied := h.resolveTokenRoles(app, user) if denied { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 84a7a74..df52b64 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -34,6 +34,9 @@ type Handler struct { // localUserMu serializes app-local user provisioning so the existence check // and the create+mapping are atomic (L3). localUserMu sync.Mutex + // migrationMu serializes a migration commit's token-claim + Apply + consume so + // the single-use token cannot be raced into a double import. + migrationMu sync.Mutex } func New(cfg *config.Config, s store.Store, jwtMgr *auth.JWTManager, uiFS fs.FS, version string) *Handler { @@ -255,6 +258,15 @@ func (h *Handler) registerRoutes(uiFS fs.FS) { h.mux.HandleFunc("POST /api/admin/apps/{app_id}/rotate-secret", h.requireMasterAdmin(h.handleRotateAppSecret)) h.mux.HandleFunc("GET /api/admin/apps/{app_id}/authz", h.requireMasterAdmin(h.handleGetAppAuthz)) h.mux.HandleFunc("PUT /api/admin/apps/{app_id}/authz", h.requireMasterAdmin(h.handleSetAppAuthz)) + // Standalone->central migration: a master mints a single-use token on the + // target app; the standalone uses it to authenticate the cross-install + // preflight/commit (those are token-authed, not master-key/session authed). + h.mux.HandleFunc("POST /api/admin/apps/{app_id}/migration-token", h.requireMasterAdmin(h.handleGenMigrationToken)) + h.mux.HandleFunc("POST /api/migration/preflight", h.handleMigrationPreflight) + h.mux.HandleFunc("POST /api/migration/commit", h.handleMigrationCommit) + // Standalone side: package this deployment and push it to a central app. + h.mux.HandleFunc("POST /api/admin/migrate-to-central/preflight", h.requireMasterAdmin(h.handleMigrateToCentralPreflight)) + h.mux.HandleFunc("POST /api/admin/migrate-to-central/commit", h.requireMasterAdmin(h.handleMigrateToCentralCommit)) // Per-app admins (human users who manage an app with their own login). h.mux.HandleFunc("GET /api/admin/apps/{app_id}/admins", h.requireMasterAdmin(h.handleListAppAdminsMaster)) h.mux.HandleFunc("POST /api/admin/apps/{app_id}/admins", h.requireMasterAdmin(h.handleAddAppAdminMaster)) diff --git a/internal/handler/homeapp_test.go b/internal/handler/homeapp_test.go new file mode 100644 index 0000000..f390552 --- /dev/null +++ b/internal/handler/homeapp_test.go @@ -0,0 +1,281 @@ +package handler + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "testing" + + "simpleauth/internal/auth" + "simpleauth/internal/store" +) + +func tokenPerms(t *testing.T, jwtStr string) []string { + t.Helper() + parts := strings.Split(jwtStr, ".") + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var c struct { + Permissions []string `json:"permissions"` + } + if err := json.Unmarshal(payload, &c); err != nil { + t.Fatalf("unmarshal perms: %v", err) + } + return c.Permissions +} + +// TestHomeAppRoleModel covers the v2 reshape: the default ("home") app IS the v1 +// global world — a user's per-user global roles, the global role→permission +// catalog, direct perms, and default_roles all flow into the home token. The same +// global roles must NEVER leak into a NAMED app's token (named apps are strictly +// per-app; the global-roles fallback is gone for them). +func TestHomeAppRoleModel(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + + // Global registry: define the role→permission catalog + permission registry. + doJSON(h, "PUT", "/api/admin/permissions", []string{"invoice:write", "extra:perm"}, adm) + doJSON(h, "PUT", "/api/admin/role-permissions", map[string][]string{ + "admin": {"invoice:write"}, + "viewer": {}, + }, adm) + + // Alice gets the global role "admin" via the per-user roles editor (= her + // home-app role). + w := doJSON(h, "POST", "/api/admin/users", map[string]interface{}{ + "display_name": "Alice", "password": "pass1234", + }, adm) + var alice map[string]interface{} + parseJSON(t, w, &alice) + aliceGUID := alice["guid"].(string) + s.SetIdentityMapping("local", "alice", aliceGUID) + + if w := doJSON(h, "PUT", "/api/admin/users/"+aliceGUID+"/roles", []string{"admin"}, adm); w.Code != http.StatusOK { + t.Fatalf("set roles: %d %s", w.Code, w.Body.String()) + } + // ...stored in the global per-user role store, and reads back through the API. + if w := doJSON(h, "GET", "/api/admin/users/"+aliceGUID+"/roles", nil, adm); w.Code != http.StatusOK { + t.Fatalf("get roles: %d", w.Code) + } else { + var roles []string + parseJSON(t, w, &roles) + if len(roles) != 1 || roles[0] != "admin" { + t.Fatalf("get roles = %v, want [admin]", roles) + } + } + // Direct (non-role) permission for Alice — inherited by the home app only. + doJSON(h, "PUT", "/api/admin/users/"+aliceGUID+"/permissions", []string{"extra:perm"}, adm) + + login := func(appID string) map[string]interface{} { + body := map[string]interface{}{"username": "alice", "password": "pass1234"} + if appID != "" { + body["app_id"] = appID + } + w := doJSON(h, "POST", "/api/auth/login", body, nil) + if w.Code != http.StatusOK { + t.Fatalf("login app=%q: %d %s", appID, w.Code, w.Body.String()) + } + var tok map[string]interface{} + parseJSON(t, w, &tok) + return tok + } + + // Home app (no app_id): token carries the assigned role + role-derived perm + // (inherited from the global catalog) + the direct perm. + at := login("")["access_token"].(string) + if roles := tokenRoles(t, at); !hasAud(roles, "admin") { + t.Fatalf("home token should carry [admin], got %v", roles) + } + if perms := tokenPerms(t, at); !hasAud(perms, "invoice:write") || !hasAud(perms, "extra:perm") { + t.Fatalf("home token perms should include invoice:write + extra:perm, got %v", perms) + } + + // Named app "billing" with its own authz that does NOT assign alice: her + // home-app role must NOT leak into the billing token. + doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + doJSON(h, "PUT", "/api/admin/apps/billing/authz", map[string]interface{}{"roles": []string{"clerk"}}, adm) + bt := login("billing")["access_token"].(string) + if roles := tokenRoles(t, bt); len(roles) != 0 { + t.Fatalf("billing token must have no roles for unassigned alice (no global leak), got %v", roles) + } + if perms := tokenPerms(t, bt); len(perms) != 0 { + t.Fatalf("billing token must not inherit home/direct perms, got %v", perms) + } +} + +// TestHomeAppDefaultRolesBaseline: default_roles are the home app's baseline for +// users with no explicit assignment, and an explicit assignment overrides it. +func TestHomeAppDefaultRolesBaseline(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + + doJSON(h, "PUT", "/api/admin/role-permissions", map[string][]string{"viewer": {}, "admin": {}}, adm) + doJSON(h, "PUT", "/api/admin/defaults/roles", []string{"viewer"}, adm) + + mkLogin := func(username string) string { + w := doJSON(h, "POST", "/api/admin/users", map[string]interface{}{ + "display_name": username, "password": "pass1234", + }, adm) + var u map[string]interface{} + parseJSON(t, w, &u) + guid := u["guid"].(string) + s.SetIdentityMapping("local", username, guid) + return guid + } + + // Unassigned user -> gets the default_roles baseline in the home token. + mkLogin("newbie") + w := doJSON(h, "POST", "/api/auth/login", map[string]interface{}{"username": "newbie", "password": "pass1234"}, nil) + if w.Code != http.StatusOK { + t.Fatalf("newbie login: %d %s", w.Code, w.Body.String()) + } + var tok map[string]interface{} + parseJSON(t, w, &tok) + if roles := tokenRoles(t, tok["access_token"].(string)); !hasAud(roles, "viewer") { + t.Fatalf("unassigned user should get default_roles [viewer], got %v", roles) + } + + // Explicitly-assigned user -> exact roles, NOT the default baseline. + guid := mkLogin("boss") + doJSON(h, "PUT", "/api/admin/users/"+guid+"/roles", []string{"admin"}, adm) + w = doJSON(h, "POST", "/api/auth/login", map[string]interface{}{"username": "boss", "password": "pass1234"}, nil) + parseJSON(t, w, &tok) + if roles := tokenRoles(t, tok["access_token"].(string)); !hasAud(roles, "admin") || hasAud(roles, "viewer") { + t.Fatalf("assigned user should get [admin] only (no default baseline), got %v", roles) + } +} + +// TestDefaultAppAuthzSurfaceForbidden: the per-app authz surfaces (admin editor + +// self-service GET/PUT/bootstrap) refuse the default app, since the home app's +// roles live in the global store, not its AppAuthz. This keeps the legacy client +// secret / a home-app admin from touching that surface. Named apps are unaffected. +func TestDefaultAppAuthzSurfaceForbidden(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + defID := h.defaultAppID() + + // Admin per-app authz editor on the default app -> 403. + if w := doJSON(h, "PUT", "/api/admin/apps/"+defID+"/authz", map[string]interface{}{"roles": []string{"x"}}, adm); w.Code != http.StatusForbidden { + t.Fatalf("admin authz PUT on default app: want 403, got %d %s", w.Code, w.Body.String()) + } + + // Self-service surface on the default app -> 403 (needs the app to exist with a + // secret so requireApp authenticates before the guard fires). + secret := "home-secret-1234" + hash, _ := auth.HashPassword(secret) + if err := s.CreateApp(&store.App{AppID: defID, Audience: defID, SecretHash: hash}); err != nil { + t.Fatalf("create default app: %v", err) + } + creds := basicAuth(defID, secret) + cases := []struct { + method, path string + body interface{} + }{ + {"GET", "/api/app/authz", nil}, + {"PUT", "/api/app/authz", map[string]interface{}{"roles": []string{"x"}}}, + {"POST", "/api/app/bootstrap", map[string]interface{}{"roles": []string{"x"}}}, + } + for _, tc := range cases { + if w := doJSON(h, tc.method, tc.path, tc.body, creds); w.Code != http.StatusForbidden { + t.Fatalf("%s %s on default app: want 403, got %d %s", tc.method, tc.path, w.Code, w.Body.String()) + } + } + + // Sanity: a NAMED app's self-service authz still works (guard is default-only). + w := doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + var app map[string]interface{} + parseJSON(t, w, &app) + if w := doJSON(h, "GET", "/api/app/authz", nil, basicAuth("billing", app["app_secret"].(string))); w.Code != http.StatusOK { + t.Fatalf("named app self-service authz GET: want 200, got %d %s", w.Code, w.Body.String()) + } +} + +// TestHomeAppRequireAssignment: require_assignment on the home app means an +// explicit global-role grant is required. The default_roles baseline does NOT +// satisfy it (fails closed); an explicitly-assigned user is granted exactly their +// roles with no baseline added. +func TestHomeAppRequireAssignment(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + defID := h.defaultAppID() + + if err := s.CreateApp(&store.App{AppID: defID, Audience: defID, RequireAssignment: true}); err != nil { + t.Fatalf("create default app: %v", err) + } + doJSON(h, "PUT", "/api/admin/role-permissions", map[string][]string{"viewer": {}, "admin": {}}, adm) + doJSON(h, "PUT", "/api/admin/defaults/roles", []string{"viewer"}, adm) + + mk := func(name string, roles []string) { + w := doJSON(h, "POST", "/api/admin/users", map[string]interface{}{"display_name": name, "password": "pass1234"}, adm) + var u map[string]interface{} + parseJSON(t, w, &u) + guid := u["guid"].(string) + s.SetIdentityMapping("local", name, guid) + if roles != nil { + doJSON(h, "PUT", "/api/admin/users/"+guid+"/roles", roles, adm) + } + } + + // No global role -> default_roles do NOT satisfy require_assignment -> DENIED. + mk("nobody", nil) + if w := doJSON(h, "POST", "/api/auth/login", map[string]interface{}{"username": "nobody", "password": "pass1234"}, nil); w.Code != http.StatusForbidden { + t.Fatalf("require_assignment home app: unassigned user must be denied, got %d %s", w.Code, w.Body.String()) + } + + // Explicit global role -> granted with exactly that role (no default baseline). + mk("somebody", []string{"admin"}) + w := doJSON(h, "POST", "/api/auth/login", map[string]interface{}{"username": "somebody", "password": "pass1234"}, nil) + if w.Code != http.StatusOK { + t.Fatalf("require_assignment home app: user with global role must be granted, got %d %s", w.Code, w.Body.String()) + } + var tok map[string]interface{} + parseJSON(t, w, &tok) + if roles := tokenRoles(t, tok["access_token"].(string)); !hasAud(roles, "admin") || hasAud(roles, "viewer") { + t.Fatalf("want [admin] only (no default baseline), got %v", roles) + } +} + +// TestDefaultAppReservedAndUndeletable: the default ("home") app is first-class — +// it cannot be deleted, its id cannot be re-registered as a named app, it cannot +// enable local users, and its per-app authz GET is refused. Named apps are +// unaffected. This keeps the home/named split (which keys off the default app id) +// from being subverted by re-registering or repurposing that id. +func TestDefaultAppReservedAndUndeletable(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + defID := h.defaultAppID() + + if w := doJSON(h, "DELETE", "/api/admin/apps/"+defID, nil, adm); w.Code != http.StatusForbidden { + t.Fatalf("delete default app: want 403, got %d %s", w.Code, w.Body.String()) + } + if w := doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": defID, "audience": defID}, adm); w.Code != http.StatusBadRequest { + t.Fatalf("create app with reserved default id: want 400, got %d %s", w.Code, w.Body.String()) + } + + // allow_local_users cannot be enabled on the default app (would be dead config). + if err := s.CreateApp(&store.App{AppID: defID, Audience: defID}); err != nil { + t.Fatalf("seed default app: %v", err) + } + if w := doJSON(h, "PUT", "/api/admin/apps/"+defID, map[string]interface{}{"allow_local_users": true}, adm); w.Code != http.StatusBadRequest { + t.Fatalf("enable local users on default app: want 400, got %d %s", w.Code, w.Body.String()) + } + // ...but other updates to the default app still work. + if w := doJSON(h, "PUT", "/api/admin/apps/"+defID, map[string]interface{}{"name": "Home"}, adm); w.Code != http.StatusOK { + t.Fatalf("rename default app: want 200, got %d %s", w.Code, w.Body.String()) + } + // admin GET authz on the default app -> 403 (matches the PUT guard). + if w := doJSON(h, "GET", "/api/admin/apps/"+defID+"/authz", nil, adm); w.Code != http.StatusForbidden { + t.Fatalf("admin GET authz on default app: want 403, got %d %s", w.Code, w.Body.String()) + } + + // A NAMED app is fully manageable (create + delete). + if w := doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm); w.Code != http.StatusCreated { + t.Fatalf("create named app: want 201, got %d %s", w.Code, w.Body.String()) + } + if w := doJSON(h, "DELETE", "/api/admin/apps/billing", nil, adm); w.Code != http.StatusOK { + t.Fatalf("delete named app: want 200, got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handler/hosted_login.go b/internal/handler/hosted_login.go index c071af4..3a03cd9 100644 --- a/internal/handler/hosted_login.go +++ b/internal/handler/hosted_login.go @@ -187,9 +187,6 @@ func (h *Handler) handleHostedLoginSubmit(w http.ResponseWriter, r *http.Request return } - // Assign default roles if needed - h.assignDefaultRoles(user.GUID) - // Issue tokens (per-app roles + require_assignment, v2 M3) roles, perms, denied := h.resolveTokenRoles(app, user) if denied { diff --git a/internal/handler/migration_central.go b/internal/handler/migration_central.go new file mode 100644 index 0000000..d724600 --- /dev/null +++ b/internal/handler/migration_central.go @@ -0,0 +1,228 @@ +package handler + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "simpleauth/internal/migrate" + "simpleauth/internal/store" +) + +// --- Central side: receive a standalone deployment as a named app --- +// +// Importing a directory is a master-level operation, so it is gated by a +// single-use, app-scoped MIGRATION TOKEN that a master admin mints on the target +// app. The standalone authenticates the cross-install preflight/commit calls with +// that token (Authorization: Bearer). Tokens are stored hashed, expire, and are +// consumed on commit. + +const migrationTokenTTL = 30 * time.Minute + +type migrationTokenRecord struct { + Hash string `json:"hash"` + ExpiresAt time.Time `json:"expires_at"` + Used bool `json:"used"` + // AppCreatedAt binds the token to this app INSTANCE: if the app_id is deleted + // and re-registered, the new app has a different CreatedAt and the old token + // no longer authenticates (it would otherwise take over an unrelated new app). + AppCreatedAt time.Time `json:"app_created_at"` +} + +func migrationTokenKey(appID string) string { return "migration_token:" + appID } + +func hashMigrationToken(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} + +// handleGenMigrationToken mints a single-use migration token for a target app. +// POST /api/admin/apps/{app_id}/migration-token (master admin) +func (h *Handler) handleGenMigrationToken(w http.ResponseWriter, r *http.Request) { + appID := pathParam(r, "app_id") + if appID == h.defaultAppID() { + jsonError(w, "the default app cannot be a migration target", http.StatusForbidden) + return + } + app, err := h.store.GetApp(appID) + if err != nil { + jsonError(w, "app not found", http.StatusNotFound) + return + } + + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + jsonError(w, "failed to generate token", http.StatusInternalServerError) + return + } + raw := "sa_mig_" + base64.RawURLEncoding.EncodeToString(b) + rec := migrationTokenRecord{Hash: hashMigrationToken(raw), ExpiresAt: time.Now().UTC().Add(migrationTokenTTL), AppCreatedAt: app.CreatedAt} + data, _ := json.Marshal(rec) + if err := h.store.SetConfigValue(migrationTokenKey(appID), data); err != nil { + jsonError(w, "failed to store token", http.StatusInternalServerError) + return + } + h.audit("migration_token_issued", "admin", getClientIP(r), map[string]interface{}{"app_id": appID}) + jsonResp(w, map[string]interface{}{ + "migration_token": raw, // shown once + "app_id": appID, + "expires_at": rec.ExpiresAt, + }, http.StatusOK) +} + +// authMigration validates the bearer migration token against the target app. It +// never reveals which check failed (uniform false), is constant-time on the hash, +// and rejects a token whose app instance (CreatedAt) no longer matches. +func (h *Handler) authMigration(r *http.Request, app *store.App) bool { + tok := extractBearerToken(r) + if tok == "" || app == nil { + return false + } + data, err := h.store.GetConfigValue(migrationTokenKey(app.AppID)) + if err != nil || len(data) == 0 { + return false + } + var rec migrationTokenRecord + if json.Unmarshal(data, &rec) != nil { + return false + } + if rec.Used || time.Now().UTC().After(rec.ExpiresAt) || !rec.AppCreatedAt.Equal(app.CreatedAt) { + return false + } + return subtle.ConstantTimeCompare([]byte(rec.Hash), []byte(hashMigrationToken(tok))) == 1 +} + +// consumeMigrationToken marks the token used. It returns an error if the claim +// could not be persisted, so the caller can fail closed BEFORE applying (a +// silently-failed consume would otherwise leave the token replayable). +func (h *Handler) consumeMigrationToken(appID string) error { + data, err := h.store.GetConfigValue(migrationTokenKey(appID)) + if err != nil { + return err + } + if len(data) == 0 { + return fmt.Errorf("token not found") + } + var rec migrationTokenRecord + if err := json.Unmarshal(data, &rec); err != nil { + return err + } + rec.Used = true + out, err := json.Marshal(rec) + if err != nil { + return err + } + return h.store.SetConfigValue(migrationTokenKey(appID), out) +} + +type migrationRequest struct { + AppID string `json:"app_id"` + Bundle *migrate.Bundle `json:"bundle"` + CarrySecret bool `json:"carry_secret"` +} + +// guardMigrationCall does the shared validation for preflight/commit: rate-limit, +// parse, token auth, schema compatibility, and target-app sanity. Returns the +// parsed request, or false if it already wrote an error response. +func (h *Handler) guardMigrationCall(w http.ResponseWriter, r *http.Request) (*migrationRequest, bool) { + if !h.loginLimiter.allow(getClientIP(r)) { + jsonError(w, "too many requests", http.StatusTooManyRequests) + return nil, false + } + var req migrationRequest + if err := readJSON(r, &req); err != nil || req.Bundle == nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return nil, false + } + if req.Bundle.SchemaRev != migrate.SchemaRev { + jsonError(w, "incompatible migration bundle — upgrade the older deployment first", http.StatusBadRequest) + return nil, false + } + if req.AppID == h.defaultAppID() { + jsonError(w, "the default app cannot be a migration target", http.StatusForbidden) + return nil, false + } + // Authenticate BEFORE revealing any app state: a missing app and a bad token + // both return the same 401, so an unauthenticated caller can't enumerate which + // app_ids exist. Disabled is only reported once the token is valid. + app, err := h.store.GetApp(req.AppID) + if err != nil || !h.authMigration(r, app) { + jsonError(w, "invalid or expired migration token", http.StatusUnauthorized) + return nil, false + } + if app.Disabled { + jsonError(w, "target app is disabled", http.StatusForbidden) + return nil, false + } + return &req, true +} + +// handleMigrationPreflight runs the dry-run classifier and returns the report, +// mutating nothing. POST /api/migration/preflight (migration-token auth) +func (h *Handler) handleMigrationPreflight(w http.ResponseWriter, r *http.Request) { + req, ok := h.guardMigrationCall(w, r) + if !ok { + return + } + rep, err := migrate.Classify(req.Bundle, h.store, req.AppID) + if err != nil { + jsonError(w, "preflight failed", http.StatusInternalServerError) + return + } + jsonResp(w, rep, http.StatusOK) +} + +// handleMigrationCommit applies the bundle and consumes the token. It re-runs the +// classifier and refuses if any user is blocked. POST /api/migration/commit +func (h *Handler) handleMigrationCommit(w http.ResponseWriter, r *http.Request) { + // Serialize commits so the token-claim and Apply are atomic: a second + // concurrent commit blocks here, then fails auth once the token is consumed — + // closing the TOCTOU that would otherwise allow a double import. + h.migrationMu.Lock() + defer h.migrationMu.Unlock() + + req, ok := h.guardMigrationCall(w, r) + if !ok { + return + } + rep, err := migrate.Classify(req.Bundle, h.store, req.AppID) + if err != nil { + jsonError(w, "preflight failed", http.StatusInternalServerError) + return + } + if !rep.OK() { + // Nothing is applied, so leave the token usable for a retry after the + // operator resolves the blocked users. + jsonResp(w, map[string]interface{}{"error": "migration has blocked users — resolve them first", "report": rep}, http.StatusConflict) + return + } + // Claim the token BEFORE Apply: a partially-applied (non-transactional) commit + // must not leave a replayable token. Fail closed if the claim can't persist. + if err := h.consumeMigrationToken(req.AppID); err != nil { + jsonError(w, "could not claim the migration token — retry", http.StatusInternalServerError) + return + } + + // Apply provisions app-local users (check-then-create); take localUserMu so it + // can't race a concurrent self-service local-user create on the same app. The + // deferred unlock (in a closure) survives a panic inside Apply. + res, err := func() (*migrate.ApplyResult, error) { + h.localUserMu.Lock() + defer h.localUserMu.Unlock() + return migrate.Apply(req.Bundle, h.store, req.AppID, req.CarrySecret) + }() + if err != nil { + jsonError(w, "commit failed (token spent; if the target was partially written, clear it before retrying with a new token): "+err.Error(), http.StatusInternalServerError) + return + } + h.audit("migration_committed", "migration:"+req.AppID, getClientIP(r), map[string]interface{}{ + "app_id": req.AppID, "assignments": res.AssignmentsSet, "local_users": res.LocalUsersCreated, + }) + jsonResp(w, res, http.StatusOK) +} diff --git a/internal/handler/migration_standalone.go b/internal/handler/migration_standalone.go new file mode 100644 index 0000000..a1acce2 --- /dev/null +++ b/internal/handler/migration_standalone.go @@ -0,0 +1,124 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "simpleauth/internal/migrate" +) + +// --- Standalone side: push this deployment into a central app --- +// +// A master admin on the standalone enters the central URL, the target app_id, and +// the migration token the central minted. The standalone packages its directory + +// authz policy and calls the central's preflight (dry run) / commit. The whole +// directory (incl. local-user password hashes) crosses this link, so it goes over +// the operator-supplied URL with TLS verification ON (default http.Client). + +// migrationHTTP is the outbound client for cross-install calls. It refuses to +// follow redirects: a migration target must not bounce us (a redirect could +// downgrade https->http or steer the directory bundle to an unintended host). +var migrationHTTP = &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return fmt.Errorf("central must not redirect") + }, +} + +// isLoopbackHost reports whether host is localhost / a loopback IP. +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + +type migrateOutboundReq struct { + CentralURL string `json:"central_url"` + AppID string `json:"app_id"` + Token string `json:"token"` + CarrySecret bool `json:"carry_secret"` +} + +// callCentral packages this deployment and POSTs it to the central path with the +// migration token; it relays the central's status + body back to the caller. +func (h *Handler) callCentral(w http.ResponseWriter, r *http.Request, path string) { + var req migrateOutboundReq + if err := readJSON(r, &req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + base := strings.TrimRight(strings.TrimSpace(req.CentralURL), "/") + u, err := url.Parse(base) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + jsonError(w, "central URL must be a valid http(s) URL", http.StatusBadRequest) + return + } + // The bundle carries local-user password hashes + the app secret hash, so it + // must not cross the network in cleartext: require https for non-loopback. + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + jsonError(w, "central URL must use https:// (http is only allowed to localhost)", http.StatusBadRequest) + return + } + + bundle, err := migrate.Package(h.store, h.defaultAppID(), h.version) + if err != nil { + jsonError(w, "failed to package this deployment: "+err.Error(), http.StatusInternalServerError) + return + } + payload, _ := json.Marshal(map[string]interface{}{ + "app_id": req.AppID, "bundle": bundle, "carry_secret": req.CarrySecret, + }) + + creq, err := http.NewRequest("POST", base+path, bytes.NewReader(payload)) + if err != nil { + jsonError(w, "bad central URL", http.StatusBadRequest) + return + } + creq.Header.Set("Content-Type", "application/json") + creq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.Token)) + + resp, err := migrationHTTP.Do(creq) + if err != nil { + jsonError(w, "could not reach the central deployment: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + + if path == "/api/migration/commit" && resp.StatusCode == http.StatusOK { + h.audit("migration_pushed", "admin", getClientIP(r), map[string]interface{}{ + "central": base, "app_id": req.AppID, + }) + } + // Relay the central's verdict verbatim so the UI shows the real report/result. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + if len(body) > 0 { + w.Write(body) + } else { + fmt.Fprint(w, "{}") + } +} + +// handleMigrateToCentralPreflight asks the central for a dry-run report. +// POST /api/admin/migrate-to-central/preflight (master admin) +func (h *Handler) handleMigrateToCentralPreflight(w http.ResponseWriter, r *http.Request) { + h.callCentral(w, r, "/api/migration/preflight") +} + +// handleMigrateToCentralCommit packages + commits this deployment to the central. +// POST /api/admin/migrate-to-central/commit (master admin) +func (h *Handler) handleMigrateToCentralCommit(w http.ResponseWriter, r *http.Request) { + h.callCentral(w, r, "/api/migration/commit") +} diff --git a/internal/handler/migration_test.go b/internal/handler/migration_test.go new file mode 100644 index 0000000..9e1f754 --- /dev/null +++ b/internal/handler/migration_test.go @@ -0,0 +1,242 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "simpleauth/internal/migrate" + "simpleauth/internal/store" +) + +// TestCentralMigrationFlow covers the receiving (central) side end-to-end: a +// master mints a token on a target app, the standalone preflights + commits with +// it, the import lands, and the token is single-use. +func TestCentralMigrationFlow(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + + // Target named app. + w := doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + if w.Code != http.StatusCreated { + t.Fatalf("create app: %d %s", w.Code, w.Body.String()) + } + + // Default app can't be a migration target. + if w := doJSON(h, "POST", "/api/admin/apps/"+h.defaultAppID()+"/migration-token", nil, adm); w.Code != http.StatusForbidden { + t.Fatalf("token on default app: want 403, got %d", w.Code) + } + + // Mint a migration token (shown once). + w = doJSON(h, "POST", "/api/admin/apps/billing/migration-token", nil, adm) + if w.Code != http.StatusOK { + t.Fatalf("mint token: %d %s", w.Code, w.Body.String()) + } + var tokResp map[string]interface{} + parseJSON(t, w, &tokResp) + token := tokResp["migration_token"].(string) + if token == "" { + t.Fatal("no token returned") + } + + // A standalone-style bundle: one local user (no AD, so nothing blocked). + bundle := &migrate.Bundle{ + SchemaRev: migrate.SchemaRev, + SourceVersion: "2.2.0-test", + App: migrate.AppConfig{ + Audience: "billing-aud", + RedirectURIs: []string{"https://app.example/cb"}, + SecretHash: "carried-hash", + }, + Catalog: migrate.Catalog{RolePermissions: map[string][]string{"admin": {"x:write"}}}, + Users: []migrate.UserEntry{ + {Kind: migrate.KindLocal, Key: "bob", Roles: []string{"admin"}, DisplayName: "Bob", PasswordHash: "bob-hash"}, + }, + } + body := map[string]interface{}{"app_id": "billing", "bundle": bundle, "carry_secret": true} + + // Bad token -> 401. + if w := doJSON(h, "POST", "/api/migration/preflight", body, bearer("sa_mig_wrong")); w.Code != http.StatusUnauthorized { + t.Fatalf("bad token preflight: want 401, got %d", w.Code) + } + + // Preflight (dry run) with the real token. + w = doJSON(h, "POST", "/api/migration/preflight", body, bearer(token)) + if w.Code != http.StatusOK { + t.Fatalf("preflight: %d %s", w.Code, w.Body.String()) + } + var rep migrate.Report + parseJSON(t, w, &rep) + if rep.LocalUsers != 1 || !rep.OK() { + t.Fatalf("preflight report wrong: %+v", rep) + } + + // Commit. + w = doJSON(h, "POST", "/api/migration/commit", body, bearer(token)) + if w.Code != http.StatusOK { + t.Fatalf("commit: %d %s", w.Code, w.Body.String()) + } + var res migrate.ApplyResult + parseJSON(t, w, &res) + if res.AssignmentsSet != 1 || res.LocalUsersCreated != 1 { + t.Fatalf("apply result wrong: %+v", res) + } + + // The import landed: app config carried, authz set, local user materialized. + app, _ := s.GetApp("billing") + if app.Audience != "billing-aud" || app.SecretHash != "carried-hash" || !app.AllowLocalUsers { + t.Fatalf("target app not carried: %+v", app) + } + authz, _ := s.GetAppAuthz("billing") + if got := authz.UserAssignments["bob"]; len(got) != 1 || got[0] != "admin" { + t.Fatalf("bob assignment: %v", got) + } + guid, _ := s.ResolveMapping("applocal:billing", "bob") + if cu, _ := s.GetUser(guid); cu == nil || cu.PasswordHash != "bob-hash" { + t.Fatalf("local user not materialized with hash: %+v", cu) + } + + // Token is single-use: a second commit is rejected. + if w := doJSON(h, "POST", "/api/migration/commit", body, bearer(token)); w.Code != http.StatusUnauthorized { + t.Fatalf("reused token: want 401, got %d %s", w.Code, w.Body.String()) + } +} + +// TestCentralMigrationBlocksADWithoutLDAP: committing an AD-user bundle to a +// central with no LDAP is refused (409), nothing is imported. +func TestCentralMigrationBlocksADWithoutLDAP(t *testing.T) { + h, _ := testSetup(t) + adm := adminHeaders() + doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + w := doJSON(h, "POST", "/api/admin/apps/billing/migration-token", nil, adm) + var tokResp map[string]interface{} + parseJSON(t, w, &tokResp) + token := tokResp["migration_token"].(string) + + bundle := &migrate.Bundle{ + SchemaRev: migrate.SchemaRev, + SourceAD: &migrate.ADInfo{Domain: "corp.local"}, + Users: []migrate.UserEntry{{Kind: migrate.KindAD, Key: "ada", Roles: []string{"admin"}}}, + } + body := map[string]interface{}{"app_id": "billing", "bundle": bundle} + + if w := doJSON(h, "POST", "/api/migration/commit", body, bearer(token)); w.Code != http.StatusConflict { + t.Fatalf("AD user, central without LDAP: want 409, got %d %s", w.Code, w.Body.String()) + } +} + +// TestMigrationEndToEnd drives the full cross-install flow: a standalone packages +// its directory and pushes it over real HTTP to a separate central install. +func TestMigrationEndToEnd(t *testing.T) { + // Central install + a target app + a token, exposed over HTTP. + central, cs := testSetup(t) + adm := adminHeaders() + if w := doJSON(central, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm); w.Code != http.StatusCreated { + t.Fatalf("create central app: %d %s", w.Code, w.Body.String()) + } + w := doJSON(central, "POST", "/api/admin/apps/billing/migration-token", nil, adm) + var tokResp map[string]interface{} + parseJSON(t, w, &tokResp) + token := tokResp["migration_token"].(string) + + srv := httptest.NewServer(central) + defer srv.Close() + + // Standalone install: home app + catalog + a local directory user with a role. + standalone, ss := testSetup(t) + homeID := standalone.defaultAppID() + if err := ss.CreateApp(&store.App{AppID: homeID, Audience: "home-aud", RedirectURIs: []string{"https://app/cb"}, SecretHash: "src-hash"}); err != nil { + t.Fatalf("seed home app: %v", err) + } + ss.SetRolePermissions(map[string][]string{"admin": {"x:write"}}) + if err := ss.CreateUser(&store.User{GUID: "g-bob", DisplayName: "Bob", PasswordHash: "bob-hash"}); err != nil { + t.Fatalf("seed user: %v", err) + } + ss.SetIdentityMapping("local", "bob", "g-bob") + ss.SetUserRoles("g-bob", []string{"admin"}) + + call := func(path string) map[string]interface{} { + w := doJSON(standalone, "POST", path, map[string]interface{}{ + "central_url": srv.URL, "app_id": "billing", "token": token, "carry_secret": true, + }, adm) + if w.Code != http.StatusOK { + t.Fatalf("%s: %d %s", path, w.Code, w.Body.String()) + } + var out map[string]interface{} + parseJSON(t, w, &out) + return out + } + + // Dry run, then commit — both proxied standalone -> central over HTTP. + rep := call("/api/admin/migrate-to-central/preflight") + if rep["local_users"].(float64) != 1 { + t.Fatalf("preflight should see 1 local user: %+v", rep) + } + call("/api/admin/migrate-to-central/commit") + + // The central now has the app config carried + bob assigned + materialized. + app, _ := cs.GetApp("billing") + if app.Audience != "home-aud" || app.SecretHash != "src-hash" || !app.AllowLocalUsers { + t.Fatalf("central app not carried: %+v", app) + } + authz, _ := cs.GetAppAuthz("billing") + if got := authz.UserAssignments["bob"]; len(got) != 1 || got[0] != "admin" { + t.Fatalf("bob not assigned on central: %v", got) + } + guid, _ := cs.ResolveMapping("applocal:billing", "bob") + if cu, _ := cs.GetUser(guid); cu == nil || cu.PasswordHash != "bob-hash" { + t.Fatalf("bob not materialized on central: %+v", cu) + } +} + +// TestMigrationHTTPSRequired: the standalone refuses to push the directory (which +// carries password hashes) to a cleartext http:// non-loopback central. +func TestMigrationHTTPSRequired(t *testing.T) { + h, _ := testSetup(t) + w := doJSON(h, "POST", "/api/admin/migrate-to-central/preflight", map[string]interface{}{ + "central_url": "http://central.evil.example", "app_id": "billing", "token": "sa_mig_x", + }, adminHeaders()) + if w.Code != http.StatusBadRequest { + t.Fatalf("cleartext http to non-loopback must be rejected, got %d %s", w.Code, w.Body.String()) + } +} + +// TestMigrationTokenInvalidatedOnDelete: a token minted on an app does not survive +// the app being deleted and the id re-registered. +func TestMigrationTokenInvalidatedOnDelete(t *testing.T) { + h, _ := testSetup(t) + adm := adminHeaders() + doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + w := doJSON(h, "POST", "/api/admin/apps/billing/migration-token", nil, adm) + var tr map[string]interface{} + parseJSON(t, w, &tr) + token := tr["migration_token"].(string) + + doJSON(h, "DELETE", "/api/admin/apps/billing", nil, adm) + doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + + body := map[string]interface{}{"app_id": "billing", "bundle": &migrate.Bundle{SchemaRev: migrate.SchemaRev}} + if w := doJSON(h, "POST", "/api/migration/commit", body, bearer(token)); w.Code != http.StatusUnauthorized { + t.Fatalf("old token on a recreated app must 401, got %d %s", w.Code, w.Body.String()) + } +} + +// TestMigrationDisabledTarget: committing into a disabled app is refused. +func TestMigrationDisabledTarget(t *testing.T) { + h, s := testSetup(t) + adm := adminHeaders() + doJSON(h, "POST", "/api/admin/apps", map[string]interface{}{"app_id": "billing", "audience": "billing"}, adm) + w := doJSON(h, "POST", "/api/admin/apps/billing/migration-token", nil, adm) + var tr map[string]interface{} + parseJSON(t, w, &tr) + token := tr["migration_token"].(string) + + app, _ := s.GetApp("billing") + app.Disabled = true + s.UpdateApp(app) + + body := map[string]interface{}{"app_id": "billing", "bundle": &migrate.Bundle{SchemaRev: migrate.SchemaRev}} + if w := doJSON(h, "POST", "/api/migration/commit", body, bearer(token)); w.Code != http.StatusForbidden { + t.Fatalf("commit into a disabled app must 403, got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handler/oidc.go b/internal/handler/oidc.go index 4b22c76..a2dde5c 100644 --- a/internal/handler/oidc.go +++ b/internal/handler/oidc.go @@ -266,9 +266,6 @@ func (h *Handler) handleOIDCAuthorize(w http.ResponseWriter, r *http.Request) { return } - // Assign default roles - h.assignDefaultRoles(user.GUID) - // Seed shared SSO session cookie (no-op if feature disabled) h.issueSessionCookie(w, r, user.GUID) @@ -555,9 +552,6 @@ func (h *Handler) handleOIDCTokenPassword(w http.ResponseWriter, r *http.Request return } - // Assign default roles - h.assignDefaultRoles(user.GUID) - log.Printf("[oidc] Password grant success user=%q guid=%s app=%q ip=%s", username, user.GUID, app.AppID, ip) h.issueOIDCTokens(w, r, user, scope, "", app) } diff --git a/internal/migrate/bundle.go b/internal/migrate/bundle.go new file mode 100644 index 0000000..5ec940b --- /dev/null +++ b/internal/migrate/bundle.go @@ -0,0 +1,377 @@ +// Package migrate builds and applies migration BUNDLES that move a standalone +// SimpleAuth deployment into a single named app on a central deployment. +// +// What moves is the AUTHORIZATION POLICY, not signing keys or PII we can avoid: +// - the source home app's config (audience, redirect_uris, cors, secret hash, +// flags) is carried onto the target named app, so the consumer app's existing +// client_id / secret / redirect_uri keep working; +// - the role->permission catalog becomes the target app's per-app authz; +// - each user's EFFECTIVE roles travel keyed by a PORTABLE identity — +// - AD users -> sAMAccountName: the central re-binds them from the SAME +// AD on login; no record or password is copied. +// - local users -> username, with their password hash, materialized as +// app-local users on the target app. +// +// The split is by AUTHENTICATION method, because that is the thing that does not +// move: a local user carries their hash; an AD user needs the central on the same +// AD. Classify (dry-run) reports whether every user is satisfiable on the central +// BEFORE any write; Apply performs the import. +package migrate + +import ( + "fmt" + "sort" + "strings" + "time" + + "simpleauth/internal/store" +) + +// SchemaRev is the bundle wire-format revision; bump on incompatible changes. +const SchemaRev = 1 + +// UserKind is how a user authenticates, which determines how they migrate. +type UserKind string + +const ( + KindAD UserKind = "ad" // AD-backed: re-bound from the central's AD; key = sAMAccountName + KindLocal UserKind = "local" // local password: materialized as an app-local user +) + +// Bundle is the portable migration payload. +type Bundle struct { + SchemaRev int `json:"schema_rev"` + SourceVersion string `json:"source_version"` + CreatedAt time.Time `json:"created_at"` + + // SourceAD describes the source's directory binding (nil if not AD-connected), + // so the central can confirm it is the SAME AD before resolving AD users. + SourceAD *ADInfo `json:"source_ad,omitempty"` + + App AppConfig `json:"app"` + Catalog Catalog `json:"catalog"` + Users []UserEntry `json:"users"` +} + +// ADInfo identifies the source's Active Directory binding. +type ADInfo struct { + Domain string `json:"domain,omitempty"` + BaseDN string `json:"base_dn,omitempty"` +} + +// AppConfig is the source home app's config, carried onto the target app. +type AppConfig struct { + Audience string `json:"audience,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + CORSOrigins []string `json:"cors_origins,omitempty"` + SecretHash string `json:"secret_hash,omitempty"` + RequireAssignment bool `json:"require_assignment"` +} + +// Catalog is the source's role/permission definitions. +type Catalog struct { + RolePermissions map[string][]string `json:"role_permissions,omitempty"` + Permissions []string `json:"permissions,omitempty"` + DefaultRoles []string `json:"default_roles,omitempty"` +} + +// UserEntry is one user's portable identity + effective roles. +type UserEntry struct { + Kind UserKind `json:"kind"` + Key string `json:"key"` // sAMAccountName (AD) or username (local) + Roles []string `json:"roles,omitempty"` // effective roles (explicit, or default_roles) + + // DirectPerms are the user's direct (non-role) permissions. Reported by the + // dry-run; NOT applied in this revision (named apps derive perms from roles). + DirectPerms []string `json:"direct_perms,omitempty"` + + // Local-only: enough to materialize an app-local user with the same login. + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + PasswordHash string `json:"password_hash,omitempty"` +} + +// Package builds a Bundle from a standalone deployment's store, capturing its home +// app (homeAppID) config + the whole directory. +func Package(s store.Store, homeAppID, sourceVersion string) (*Bundle, error) { + b := &Bundle{SchemaRev: SchemaRev, SourceVersion: sourceVersion, CreatedAt: time.Now().UTC()} + + if ldap, _ := s.GetLDAPConfig(); ldap != nil && (ldap.Domain != "" || ldap.BaseDN != "") { + b.SourceAD = &ADInfo{Domain: ldap.Domain, BaseDN: ldap.BaseDN} + } + + if app, err := s.GetApp(homeAppID); err == nil { + aud := app.Audience + if aud == "" { + aud = app.AppID + } + b.App = AppConfig{ + Audience: aud, + RedirectURIs: app.RedirectURIs, + CORSOrigins: app.CORSOrigins, + SecretHash: app.SecretHash, + RequireAssignment: app.RequireAssignment, + } + } + + rp, _ := s.GetRolePermissions() + perms, _ := s.GetDefinedPermissions() + defs, _ := s.GetDefaultRoles() + b.Catalog = Catalog{RolePermissions: rp, Permissions: perms, DefaultRoles: defs} + + users, err := s.ListUsers() + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + for _, u := range users { + if u.MergedInto != "" { + continue // merged-away shadow + } + // App-local users owned by ANOTHER app aren't part of this home directory. + if u.OwnerAppID != "" && u.OwnerAppID != homeAppID { + continue + } + if entry, ok := classifyUser(s, u, b.Catalog.DefaultRoles); ok { + b.Users = append(b.Users, entry) + } + } + return b, nil +} + +// classifyUser turns a source user into a portable UserEntry. ok=false skips a +// user with no usable identity. +func classifyUser(s store.Store, u *store.User, defaultRoles []string) (UserEntry, bool) { + roles, _ := s.GetUserRoles(u.GUID) + if len(roles) == 0 { + roles = defaultRoles // effective baseline the user had on the home app + } + direct, _ := s.GetUserPermissions(u.GUID) + + if u.PasswordHash != "" { + username := localUsername(s, u) + if username == "" { + return UserEntry{}, false + } + return UserEntry{ + Kind: KindLocal, Key: username, Roles: roles, DirectPerms: direct, + DisplayName: u.DisplayName, Email: u.Email, PasswordHash: u.PasswordHash, + }, true + } + if u.SAMAccountName != "" { + return UserEntry{Kind: KindAD, Key: u.SAMAccountName, Roles: roles, DirectPerms: direct}, true + } + return UserEntry{}, false +} + +// localUsername finds the login username for a local user. +func localUsername(s store.Store, u *store.User) string { + mappings, _ := s.GetMappingsForUser(u.GUID) + for _, m := range mappings { + if m.Provider == "local" { + return m.ExternalID + } + } + for _, m := range mappings { + if strings.HasPrefix(m.Provider, "applocal:") { + return m.ExternalID + } + } + if u.SAMAccountName != "" { + return u.SAMAccountName + } + return u.Email +} + +// Report is the dry-run result the central computes before any write. +type Report struct { + SourceVersion string `json:"source_version"` + TargetApp string `json:"target_app"` + + ADUsersSameDomain int `json:"ad_users_same_domain"` // resolvable from the central's AD + ADUsersKnown int `json:"ad_users_known"` // already present in the central directory + LocalUsers int `json:"local_users"` // materialized as app-local users + Blocked []BlockedUser `json:"blocked,omitempty"` + Notes []string `json:"notes,omitempty"` + + RedirectURIsToReview []string `json:"redirect_uris_to_review,omitempty"` +} + +// BlockedUser is a user the central cannot satisfy as-is. +type BlockedUser struct { + Key string `json:"key"` + Reason string `json:"reason"` +} + +// OK reports whether the migration can proceed (no blocked users). +func (r *Report) OK() bool { return len(r.Blocked) == 0 } + +// Classify computes the dry-run report. It validates every user is satisfiable on +// the central WITHOUT mutating anything. +func Classify(b *Bundle, central store.Store, targetAppID string) (*Report, error) { + r := &Report{SourceVersion: b.SourceVersion, TargetApp: targetAppID, RedirectURIsToReview: b.App.RedirectURIs} + + // Fresh-target guard: Apply wholesale-replaces the target's authz, so refuse a + // target that already has ANY per-app authorization — user OR group + // assignments, roles, role→perm map, or a permission catalog. Migrate into a + // freshly-created app to avoid clobbering an in-use one. + if cur, _ := central.GetAppAuthz(targetAppID); cur != nil && (len(cur.UserAssignments) > 0 || len(cur.GroupAssignments) > 0 || len(cur.RolePermissions) > 0 || len(cur.Roles) > 0 || len(cur.Permissions) > 0) { + r.Blocked = append(r.Blocked, BlockedUser{Key: targetAppID, Reason: "target app already has authorization configured — migrate into a freshly-created app"}) + return r, nil + } + + centralLDAP, _ := central.GetLDAPConfig() + centralHasAD := centralLDAP != nil && (centralLDAP.Domain != "" || centralLDAP.BaseDN != "") + sameAD := centralHasAD && b.SourceAD != nil && sameADDomain(b.SourceAD, centralLDAP) + + known := map[string]bool{} + if cu, err := central.ListUsers(); err == nil { + for _, u := range cu { + if u.SAMAccountName != "" { + known[u.SAMAccountName] = true + } + } + } + + directPermUsers := 0 + for _, u := range b.Users { + if len(u.DirectPerms) > 0 { + directPermUsers++ + } + switch u.Kind { + case KindLocal: + r.LocalUsers++ + case KindAD: + switch { + case !centralHasAD: + r.Blocked = append(r.Blocked, BlockedUser{Key: u.Key, Reason: "central is not connected to AD; cannot authenticate this user"}) + case !sameAD: + r.Blocked = append(r.Blocked, BlockedUser{Key: u.Key, Reason: "central is on a different AD; key by UPN/email or connect the same AD"}) + default: + r.ADUsersSameDomain++ + if known[u.Key] { + r.ADUsersKnown++ + } + } + } + } + + if directPermUsers > 0 { + r.Notes = append(r.Notes, fmt.Sprintf("%d user(s) have direct (non-role) permissions that are NOT carried in this version — re-grant via roles on the target app", directPermUsers)) + } + if b.SourceAD != nil && !centralHasAD { + r.Notes = append(r.Notes, "source is AD-connected but the central is not — connect the central to the same AD to migrate AD users") + } + return r, nil +} + +func sameADDomain(src *ADInfo, c *store.LDAPConfig) bool { + if src.Domain != "" && c.Domain != "" { + return strings.EqualFold(strings.TrimSpace(src.Domain), strings.TrimSpace(c.Domain)) + } + if src.BaseDN != "" && c.BaseDN != "" { + return strings.EqualFold(strings.TrimSpace(src.BaseDN), strings.TrimSpace(c.BaseDN)) + } + return false +} + +// ApplyResult summarizes a committed import. +type ApplyResult struct { + AppUpdated bool `json:"app_updated"` + RolesDefined int `json:"roles_defined"` + AssignmentsSet int `json:"assignments_set"` + LocalUsersCreated int `json:"local_users_created"` + Skipped int `json:"skipped"` +} + +// Apply imports the bundle into targetAppID on the central store. Additive, and +// idempotent for local users (an existing app-local username is left in place). +// carrySecret copies the source app's secret hash so the consumer's existing +// secret keeps working; pass false to keep the target app's own secret. +func Apply(b *Bundle, central store.Store, targetAppID string, carrySecret bool) (*ApplyResult, error) { + res := &ApplyResult{} + + app, err := central.GetApp(targetAppID) + if err != nil { + return nil, fmt.Errorf("target app: %w", err) + } + + if b.App.Audience != "" { + app.Audience = b.App.Audience + } + if len(b.App.RedirectURIs) > 0 { + app.RedirectURIs = b.App.RedirectURIs + } + if len(b.App.CORSOrigins) > 0 { + app.CORSOrigins = b.App.CORSOrigins + } + // Never WEAKEN the target's access gate via a migration: OR-in only. A target + // the operator deliberately created with require_assignment=true must not be + // downgraded to open by a source home app that ran with it off. + app.RequireAssignment = app.RequireAssignment || b.App.RequireAssignment + if carrySecret && b.App.SecretHash != "" { + app.SecretHash = b.App.SecretHash + } + for _, u := range b.Users { + if u.Kind == KindLocal { + app.AllowLocalUsers = true + break + } + } + if err := central.UpdateApp(app); err != nil { + return nil, fmt.Errorf("update app: %w", err) + } + res.AppUpdated = true + + authz, _ := central.GetAppAuthz(targetAppID) + if authz == nil { + authz = &store.AppAuthz{AppID: targetAppID} + } + authz.AppID = targetAppID + if b.Catalog.RolePermissions != nil { + authz.RolePermissions = b.Catalog.RolePermissions + authz.Roles = sortedKeys(b.Catalog.RolePermissions) + } + if len(b.Catalog.Permissions) > 0 { + authz.Permissions = b.Catalog.Permissions + } + if authz.UserAssignments == nil { + authz.UserAssignments = map[string][]string{} + } + res.RolesDefined = len(authz.Roles) + + mapKey := "applocal:" + targetAppID + for _, u := range b.Users { + if len(u.Roles) == 0 { + res.Skipped++ // nothing to grant + continue + } + if u.Kind == KindLocal { + if existing, _ := central.ResolveMapping(mapKey, u.Key); existing == "" { + nu := &store.User{OwnerAppID: targetAppID, DisplayName: u.DisplayName, Email: u.Email, PasswordHash: u.PasswordHash, CreatedAt: time.Now().UTC()} + if err := central.CreateUser(nu); err != nil { + return nil, fmt.Errorf("create local user %q: %w", u.Key, err) + } + if err := central.SetIdentityMapping(mapKey, u.Key, nu.GUID); err != nil { + return nil, fmt.Errorf("map local user %q: %w", u.Key, err) + } + res.LocalUsersCreated++ + } + } + authz.UserAssignments[u.Key] = u.Roles + res.AssignmentsSet++ + } + + if err := central.SaveAppAuthz(authz); err != nil { + return nil, fmt.Errorf("save authz: %w", err) + } + return res, nil +} + +func sortedKeys(m map[string][]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/migrate/bundle_test.go b/internal/migrate/bundle_test.go new file mode 100644 index 0000000..1a660df --- /dev/null +++ b/internal/migrate/bundle_test.go @@ -0,0 +1,228 @@ +package migrate + +import ( + "testing" + + "simpleauth/internal/store" +) + +func open(t *testing.T) store.Store { + t.Helper() + s, err := store.Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// seedSource builds a standalone, AD-connected source: catalog + default_roles + +// a home app + two AD users (one explicit-role, one default-role) + one local user. +func seedSource(t *testing.T) store.Store { + t.Helper() + s := open(t) + must(t, s.SaveLDAPConfig(&store.LDAPConfig{Domain: "corp.local", BaseDN: "dc=corp,dc=local"})) + must(t, s.SetDefinedPermissions([]string{"invoice:read", "invoice:write"})) + must(t, s.SetRolePermissions(map[string][]string{"admin": {"invoice:write"}, "viewer": {"invoice:read"}})) + must(t, s.SetDefaultRoles([]string{"viewer"})) + must(t, s.CreateApp(&store.App{ + AppID: "simpleauth", Audience: "simpleauth-aud", + RedirectURIs: []string{"https://app.example/callback"}, + CORSOrigins: []string{"https://app.example"}, + SecretHash: "carried-secret-hash", + })) + + // AD user with an explicit role + a direct permission. + must(t, s.CreateUser(&store.User{GUID: "g-ada", DisplayName: "Ada", SAMAccountName: "ada"})) + must(t, s.SetUserRoles("g-ada", []string{"admin"})) + must(t, s.SetUserPermissions("g-ada", []string{"invoice:read"})) + // AD user with NO explicit role -> effective = default_roles. + must(t, s.CreateUser(&store.User{GUID: "g-new", DisplayName: "Newbie", SAMAccountName: "newbie"})) + // Local user with a password hash + a local mapping. + must(t, s.CreateUser(&store.User{GUID: "g-loc", DisplayName: "Loc", Email: "loc@x", PasswordHash: "loc-hash"})) + must(t, s.SetIdentityMapping("local", "loc", "g-loc")) + must(t, s.SetUserRoles("g-loc", []string{"viewer"})) + return s +} + +func TestPackageClassifyApply_SameAD(t *testing.T) { + src := seedSource(t) + b, err := Package(src, "simpleauth", "2.2.0-test") + if err != nil { + t.Fatalf("package: %v", err) + } + + // Bundle captured AD binding, app config, catalog, 3 classified users. + if b.SourceAD == nil || b.SourceAD.Domain != "corp.local" { + t.Fatalf("source AD not captured: %+v", b.SourceAD) + } + if b.App.Audience != "simpleauth-aud" || b.App.SecretHash != "carried-secret-hash" || len(b.App.RedirectURIs) != 1 { + t.Fatalf("app config not captured: %+v", b.App) + } + if len(b.Users) != 3 { + t.Fatalf("want 3 users, got %d: %+v", len(b.Users), b.Users) + } + byKey := map[string]UserEntry{} + for _, u := range b.Users { + byKey[u.Key] = u + } + if e := byKey["ada"]; e.Kind != KindAD || len(e.Roles) != 1 || e.Roles[0] != "admin" || len(e.DirectPerms) != 1 { + t.Fatalf("ada entry wrong: %+v", e) + } + if e := byKey["newbie"]; e.Kind != KindAD || len(e.Roles) != 1 || e.Roles[0] != "viewer" { // default_roles + t.Fatalf("newbie effective roles should be [viewer] from defaults: %+v", e) + } + if e := byKey["loc"]; e.Kind != KindLocal || e.PasswordHash != "loc-hash" { + t.Fatalf("loc entry wrong: %+v", e) + } + + // Central on the SAME AD, with an empty target app. + central := open(t) + must(t, central.SaveLDAPConfig(&store.LDAPConfig{Domain: "corp.local"})) + must(t, central.CreateApp(&store.App{AppID: "billing", Audience: "billing"})) + + rep, err := Classify(b, central, "billing") + if err != nil { + t.Fatalf("classify: %v", err) + } + if !rep.OK() { + t.Fatalf("should be no blocked users on same AD: %+v", rep.Blocked) + } + if rep.ADUsersSameDomain != 2 || rep.LocalUsers != 1 { + t.Fatalf("report counts wrong: %+v", rep) + } + if len(rep.Notes) == 0 { // the direct-perm note + t.Fatalf("expected a direct-perm note") + } + + res, err := Apply(b, central, "billing", true) + if err != nil { + t.Fatalf("apply: %v", err) + } + if res.LocalUsersCreated != 1 || res.AssignmentsSet != 3 { + t.Fatalf("apply result wrong: %+v", res) + } + + // Target app carried config + allow_local_users. + app, _ := central.GetApp("billing") + if app.Audience != "simpleauth-aud" || app.SecretHash != "carried-secret-hash" || !app.AllowLocalUsers || len(app.RedirectURIs) != 1 { + t.Fatalf("target app config not carried: %+v", app) + } + // Authz: assignments keyed by SAM / username, role->perm carried. + authz, _ := central.GetAppAuthz("billing") + if got := authz.UserAssignments["ada"]; len(got) != 1 || got[0] != "admin" { + t.Fatalf("ada assignment: %v", got) + } + if got := authz.UserAssignments["newbie"]; len(got) != 1 || got[0] != "viewer" { + t.Fatalf("newbie assignment: %v", got) + } + if got := authz.UserAssignments["loc"]; len(got) != 1 || got[0] != "viewer" { + t.Fatalf("loc assignment: %v", got) + } + if len(authz.RolePermissions) != 2 { + t.Fatalf("role->perm not carried: %+v", authz.RolePermissions) + } + // Local user materialized with the carried hash (so the same password works). + guid, err := central.ResolveMapping("applocal:billing", "loc") + if err != nil || guid == "" { + t.Fatalf("local user not materialized: %v", err) + } + if cu, _ := central.GetUser(guid); cu == nil || cu.PasswordHash != "loc-hash" || cu.OwnerAppID != "billing" { + t.Fatalf("local user record wrong: %+v", cu) + } + + // Idempotent re-apply: no second local user, assignments unchanged. + res2, err := Apply(b, central, "billing", true) + if err != nil { + t.Fatalf("re-apply: %v", err) + } + if res2.LocalUsersCreated != 0 { + t.Fatalf("re-apply must not recreate local users, got %+v", res2) + } +} + +func TestClassify_CentralNotOnAD_BlocksADUsers(t *testing.T) { + src := seedSource(t) + b, err := Package(src, "simpleauth", "2.2.0-test") + if err != nil { + t.Fatalf("package: %v", err) + } + + central := open(t) // NO LDAP configured + must(t, central.CreateApp(&store.App{AppID: "billing", Audience: "billing"})) + + rep, err := Classify(b, central, "billing") + if err != nil { + t.Fatalf("classify: %v", err) + } + if rep.OK() { + t.Fatalf("AD users must be blocked when central has no AD") + } + if len(rep.Blocked) != 2 { // ada + newbie + t.Fatalf("want 2 blocked AD users, got %d: %+v", len(rep.Blocked), rep.Blocked) + } + if rep.LocalUsers != 1 { + t.Fatalf("local user should still be OK, got %+v", rep) + } +} + +func TestClassify_DifferentAD_BlocksADUsers(t *testing.T) { + src := seedSource(t) + b, _ := Package(src, "simpleauth", "2.2.0-test") + + central := open(t) + must(t, central.SaveLDAPConfig(&store.LDAPConfig{Domain: "other.local"})) // different AD + must(t, central.CreateApp(&store.App{AppID: "billing", Audience: "billing"})) + + rep, _ := Classify(b, central, "billing") + if rep.OK() || len(rep.Blocked) != 2 { + t.Fatalf("different-AD must block the 2 AD users: %+v", rep.Blocked) + } +} + +// TestApply_DoesNotDowngradeRequireAssignment: a source home app with the gate +// OFF must not relax a target the operator deliberately locked down. +func TestApply_DoesNotDowngradeRequireAssignment(t *testing.T) { + central := open(t) + must(t, central.CreateApp(&store.App{AppID: "payroll", Audience: "payroll", RequireAssignment: true})) + b := &Bundle{SchemaRev: SchemaRev, App: AppConfig{RequireAssignment: false}} + if _, err := Apply(b, central, "payroll", false); err != nil { + t.Fatalf("apply: %v", err) + } + if app, _ := central.GetApp("payroll"); !app.RequireAssignment { + t.Fatal("migration must not downgrade require_assignment from true to false") + } +} + +// TestClassify_FreshTargetGuard: refuse to migrate into an app that already has +// per-app authorization (would clobber an in-use app). +func TestClassify_FreshTargetGuard(t *testing.T) { + central := open(t) + must(t, central.CreateApp(&store.App{AppID: "billing", Audience: "billing"})) + must(t, central.SaveAppAuthz(&store.AppAuthz{AppID: "billing", UserAssignments: map[string][]string{"x": {"r"}}})) + b := &Bundle{SchemaRev: SchemaRev, Users: []UserEntry{{Kind: KindLocal, Key: "bob", Roles: []string{"r"}, PasswordHash: "h"}}} + rep, _ := Classify(b, central, "billing") + if rep.OK() { + t.Fatal("classify must block a non-empty target app") + } +} + +// TestClassify_FreshTargetGuard_Groups: a target configured with only GROUP +// assignments (no user assignments) is still in-use and must be protected. +func TestClassify_FreshTargetGuard_Groups(t *testing.T) { + central := open(t) + must(t, central.CreateApp(&store.App{AppID: "billing", Audience: "billing"})) + must(t, central.SaveAppAuthz(&store.AppAuthz{AppID: "billing", GroupAssignments: map[string][]string{"Finance": {"viewer"}}})) + b := &Bundle{SchemaRev: SchemaRev, Users: []UserEntry{{Kind: KindLocal, Key: "bob", Roles: []string{"r"}, PasswordHash: "h"}}} + rep, _ := Classify(b, central, "billing") + if rep.OK() { + t.Fatal("classify must block a target that has group assignments") + } +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/ui/dist/app.js b/ui/dist/app.js index dde559d..0144d24 100644 --- a/ui/dist/app.js +++ b/ui/dist/app.js @@ -46,6 +46,7 @@ const icons = { database: html``, settings: html``, apps: html``, + migrate: html``, }; // === Toast === @@ -1951,6 +1952,7 @@ function AppsPage() { const [showCreate, setShowCreate] = useState(false); const [form, setForm] = useState({ app_id: '', name: '', audience: '', require_assignment: false, allow_local_users: false }); const [secretModal, setSecretModal] = useState(null); + const [tokenModal, setTokenModal] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [authzApp, setAuthzApp] = useState(null); const [authz, setAuthz] = useState(null); @@ -1979,6 +1981,11 @@ function AppsPage() { catch (e) { showToast(e.message, 'error'); } }; + const genMigrationToken = async (id) => { + try { const res = await api('POST', `/api/admin/apps/${id}/migration-token`); setTokenModal(res); } + catch (e) { showToast(e.message, 'error'); } + }; + const del = async (id) => { try { await api('DELETE', `/api/admin/apps/${id}`); setConfirmDelete(null); load(); showToast('App deleted'); } catch (e) { showToast(e.message, 'error'); } @@ -2026,6 +2033,7 @@ function AppsPage() {
Give this to the standalone deployment migrating into ${tokenModal.app_id}. It is single-use and expires ${new Date(tokenModal.expires_at).toLocaleString()}.
Delete ${confirmDelete} and all of its per-app roles and assignments? This cannot be undone.
Move this standalone deployment into a single app on a central SimpleAuth. On the central: create the target app, then use its Migrate token button. Enter the details below — Preflight is a dry run; nothing changes until you commit.
+ +${error}
`} +| User | Reason |
|---|---|
${b.key} | ${b.reason} |
${u}⚠ ${n}
`)}Now point your consumer apps at the central's URL. Their client_id, audience, redirect URI${f.carry_secret ? ', and secret are' : ' are'} unchanged — only the issuer/JWKS change, handled automatically by OIDC discovery.
+