Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.0
2.2.0
67 changes: 67 additions & 0 deletions docs/MIGRATION-STANDALONE-TO-CENTRAL.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions internal/handler/admin_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions internal/handler/admin_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
27 changes: 26 additions & 1 deletion internal/handler/app_selfservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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"`
Expand Down
89 changes: 70 additions & 19 deletions internal/handler/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading
Loading