App roles and permissions
Opt-in application authorization, one role per account, explicit delegation, and backend checks.
App roles and permissions
Application authorization is optional and independent of organization membership. Each account has one app role. A custom role has an explicit set of permission keys; there is no role hierarchy, wildcard matching, or direct user grant.
When app permissions are disabled (the default), accounts continue to use the
supported fixed admin and user roles through the user role field. Enabling
the feature selects app roles as the account authorization source.
For browser integration, see the app roles and permissions client: typed request helpers and individual React/shadcn examples for every access, definition, role, grant, provisioning, and assignment action.
Enable and initialize
Add this option to your existing NewConfig setup:
goauth.WithAppPermissions(goauth.AppPermissionsConfig{
Enable: true,
EnableManagementHTTP: true, // optional built-in management API
DefaultRoleSlug: "user",
})Apply the fresh-install schema and seed the initial protected administrator before accepting signups:
go run ./cmd/goauth migrate --driver sqlite --dsn "file:goauth.db"
ADMIN_EMAIL=owner@example.com ADMIN_PASSWORD='StrongPassw0rd!' \
go run ./cmd/goauth seed-admin --driver sqlite --dsn "file:goauth.db" \
--env dev --non-interactive --app-permissionsUse your actual driver, DSN and mailer settings in production. The existing
seed-admin mailer check runs before database writes. --force --app-permissions
creates another protected administrator in an initialized installation.
Trusted programmatic setup can call
auth.InitializeAppPermissions(ctx, api.InitializeAppPermissionsInput{AdministratorUserID: id})
for an existing eligible setup account. This operation has no HTTP endpoint.
It creates the protected roles and singleton state, assigns that account to
admin, and returns app_permissions_already_initialized on repeat initialization.
It does not assign existing accounts or install any permission definitions.
New never initializes or seeds records. With app permissions enabled,
registration before initialization fails without creating an account. Use the
canonical schema and initialize the feature before accepting accounts.
Protected roles and ordinary actions
| Role | Behavior |
|---|---|
Built-in admin (systemKey: platform_admin) | All known library actions, including actions whose optional definition is absent. All existing enabled business definitions. |
Built-in user (systemKey: user) | Empty grants; normal authenticated actions still work under their existing ownership and business rules. |
| Custom role | Only its explicit installed, enabled permission grants. Names containing admin confer no access. |
Both built-in roles have protected metadata, state and grant sets. Create a custom role to tailor access. Built-in admin cannot be renamed, disabled or given a restricted grant set. Assignment to or from protected admin requires a protected admin actor. Demotion, banning and deletion cannot remove the last usable protected administrator.
In enabled mode, users.app_role_id is authoritative. Returned users have an
empty Go User.Role, and the account role field is omitted from JSON,
including nested organization member users. Organization membership role
remains present. New account inserts leave users.role at its unused schema
default; existing values are ignored. Use appRoleId and /auth/access in your
UI. In disabled mode the existing hardcoded
admin/user behavior remains authoritative, and
auth.Services().AppPermissions is nil.
Public routes remain public. RequireAuth alone requires authentication and
does not acquire a permission requirement. A custom role or baseline user can
use actions you deliberately leave without a permission guard. Disabling a
custom role denies permission checks; it does not ban the account or remove
ordinary authenticated account operations.
Library definitions: enable delegation selectively
The library owns a fixed code catalog under goauth.app.*. Its definitions
cannot be edited, renamed or disabled. Installing a catalog key creates an
optional SQL definition that can be linked to custom roles. Creation grants
nobody access by itself.
GET /admin/authorization/library-permissions returns the fixed catalog with
key, name and description, without querying the permission table or adding
installation status. Use GET /admin/authorization/permissions to inspect
installed definitions separately.
Both catalog routes require the current protected administrator and required
session assurance. They have no delegable permission check. A role holding
every goauth.app.permissions.* key still cannot install or remove library
definitions.
PATCH /admin/authorization/library-permissions
Content-Type: application/json
{
"create": ["goauth.app.sessions.revoke", "goauth.app.users.read"],
"delete": ["goauth.app.roles.create", "goauth.app.roles.delete"]
}The batch is atomic and accepts 1–100 total keys. Unknown keys, duplicates and
create/delete overlap reject the entire request. Installing an installed key
or removing an absent key returns it in unchanged without changing revisions
or grants. The result contains created, deleted and unchanged arrays.
Removing a definition also deletes all its role links in that transaction, increments each affected custom role's revision once and advances the app revision once. A protected admin can still execute that known library action. Custom roles lose it on subsequent checks. Reinstalling creates a new definition ID with no grants; deleted links never reappear automatically.
The CLI reuses the same transactional writer and records local CLI attribution:
go run ./cmd/goauth permissions catalog
go run ./cmd/goauth permissions seed --driver sqlite --dsn "file:goauth.db" \
--actor-id "$ADMIN_ID" --keys goauth.app.sessions.revoke,goauth.app.users.read
go run ./cmd/goauth permissions seed --driver sqlite --dsn "file:goauth.db" \
--actor-id "$ADMIN_ID" --all
go run ./cmd/goauth permissions update --driver sqlite --dsn "file:goauth.db" \
--actor-id "$ADMIN_ID" --create goauth.app.users.read \
--delete goauth.app.roles.create,goauth.app.roles.deleteCatalog needs no database. CLI writes require an initialized installation and an existing, non-banned protected admin for attribution. Local access to the database is the CLI trust boundary, so it does not require a browser session or email challenge. Its audit records are database-only and fail closed. The runtime service API has no public flag that bypasses session assurance.
Business permissions, roles and assignments
The app owns keys such as app.posts.create and app.posts.delete. Keys are
immutable lowercase dot-separated identifiers, at most 160 bytes, with at
least a resource and action segment after app.. Each segment starts with a
letter and contains letters, digits or underscores.
Create a definition, create a custom role with its keys, then assign the role:
POST /admin/authorization/permissions
{"key":"app.posts.delete","name":"Delete posts","description":"Remove a post"}
POST /admin/authorization/roles
{"slug":"moderator","name":"Moderator","permissionKeys":["app.posts.delete"]}
PUT /admin/authorization/users/ACCOUNT_ID/role
{"roleId":"ROLE_ID","expectedRoleRevision":1,"expectedAssignmentRevision":1}Use IDs and revisions returned by your actual responses. Account, role and
permission IDs must be lowercase, hyphenated UUIDs; alternate UUID formats
return invalid_input before storage access. Role slugs are
immutable lowercase identifiers, at most 80 characters, starting with a letter,
then letters, digits or hyphens. admin and user are reserved. Names are
required, at most 120 characters; descriptions are at most 4096 bytes.
Grant replacement uses
PUT /admin/authorization/roles/ROLE_ID/permissions with
{"permissionKeys":[...]} with If-Match: "1". An empty array clears grants.
At most 256 distinct installed, enabled keys can be supplied. Read the latest
role revision before editing or assigning; stale updates return
authorization_conflict. A changed assignment replaces the sole previous role
and increments the account's appRoleAssignmentRevision and global app revision.
Assigning the current role with valid expected revisions is a no-op: it preserves
both revisions, account timestamps and role grants, and records no role-change
audit event. Authorization, required assurance and delegation checks still run.
This also applies to the enabled-mode Admin.UpdateUserRole API and its route.
Business definitions support metadata updates and isEnabled. Disabled or
missing business keys deny everyone, including protected admin. Deleting a
business definition requires removing all grants first. Library definitions
must instead use the protected bulk catalog endpoint. Deleting a role requires
it to be custom, unassigned, and different from the configured default.
The default role is assigned inside registration, invitation redemption and
OAuth account creation transactions. To use a custom default, initialize with
user, create the custom role, then configure its slug. An absent or disabled
configured default fails account creation; the library does not silently fall
back. Administrative creation also defaults to this role. Passing an explicit
appRoleId additionally requires goauth.app.roles.assign and the observed
expectedRoleRevision.
Ordinary registration, invitation redemption and OAuth creation use their own account transactions without the all-user administration guard or an exclusive app-state lock. They insert a provisional unassigned account first, then read the initialized state and share-lock the configured role until assignment and commit. PostgreSQL/MySQL signups can share that role concurrently; role disable/delete writers must wait. SQLite retains its native single-writer behavior. Failed initialization, role validation or invite/provider operations roll back the account and its related writes. Signup does not advance the global authorization revision.
A delegated role manager can grant or assign only keys it currently holds. Editing, clearing or deleting another role also requires holding its existing keys. A delegated actor cannot edit its own role or assign itself a role. Protected-admin transitions remain protected-admin-only. A delegated business permission updater must hold the key it updates; it cannot re-enable a disabled key to regain its own access.
Business-permission deletion also enforces the held-key bound before checking references. An unused definition therefore needs a protected administrator to delete it: a delegate holding that key has a role grant that prevents deletion, and removing that grant removes its held-key authority. See the client security guide.
Check access on the backend
The optional service capability is exposed through
auth.Services().AppPermissions. Its methods use the input structs in api.
Actor IDs and session IDs come from trusted backend authentication, never the
JSON body. A caller with no session ID is accepted only where required assurance
does not demand one; privileged direct calls obey the same MFA policy as HTTP.
permissions := auth.Services().AppPermissions
if permissions == nil {
return domain.ErrAppPermissionsDisabled
}
err := permissions.RequirePermission(ctx, api.CheckAppPermissionInput{
Actor: api.AppPermissionActor{UserID: authenticatedUser.ID, SessionID: session.ID},
PermissionKey: "app.posts.delete",
})
if err != nil {
return err
}
// Apply your resource ownership and tenant constraints, then perform the action.CheckPermission returns {allowed:false} for denial and an error for invalid
keys, assurance failures or infrastructure errors. RequirePermission turns
denial into forbidden. Unknown reserved library keys deny even admin; a typo
never grants access.
For your own HTTP routes, compose authentication and permission middleware:
mux.Handle("DELETE /posts/{id}", auth.RequireAuth(
auth.RequireAppPermission("app.posts.delete")(http.HandlerFunc(deletePost)),
))Apply your CSRF and rate-limit policy to these consumer routes as appropriate. An app permission does not check organization membership or resource ownership.
GET /auth/access returns the current role, isFullAccess, effective
permissionKeys, assignmentRevision and app revision. It is available when
app permissions are enabled, even if management HTTP is off. Use it to render
buttons; browser flags never authorize backend actions.
Keep self-service clients on /auth/access, alongside /auth/me and
/auth/sessions. This response describes application access. Organization
membership and roles have their own endpoints and authorization rules.
With EnableManagementHTTP:true, an operator can inspect another account using
GET /admin/authorization/users/{id}/access. It returns the same shape for the
target's current enabled role and effective grants. Inspection requires
goauth.app.roles.read or protected-admin access, plus the operator's required
MFA. The management route enforces this even when inspecting the operator's own
account. A missing, banned, unassigned, or disabled-role target returns 403.
Use the existing service method for direct backend inspection:
access, err := permissions.ListEffectivePermissions(ctx, api.ListAppEffectivePermissionsInput{
Actor: api.AppPermissionActor{UserID: authenticatedUser.ID, SessionID: session.ID},
UserID: targetUserID,
})Omitting UserID selects the actor's own access. Supplying another user's ID
requires the same inspection permission and assurance as the management route.
Explicit target IDs must be UUIDs. Disabling or removing a grant changes
subsequent access responses in existing sessions.
Checks reload primary-database state rather than caching grants in sessions. Changes affect subsequent checks, including existing sessions. A check does not cancel an action already authorized or automatically make a consumer's later business write part of the authorization transaction.
Existing library operations and MFA
| Operations | Delegable key |
|---|---|
| User list/count/detail | goauth.app.users.read |
| User creation | goauth.app.users.create |
| Ban / unban / delete (including corresponding bulk action) | goauth.app.users.ban / unban / delete |
| Session list/count, including per-account sessions | goauth.app.sessions.read |
| Session revocation, including bulk | goauth.app.sessions.revoke |
| Audit list/count | goauth.app.audit.read |
| Stats, registrations, login activity | goauth.app.stats.read |
| Signup invite list/count | goauth.app.invites.read |
| Signup invite create/resend/revoke/delete and corresponding bulk action | goauth.app.invites.create / resend / revoke / delete |
| Business permission list/create/update/delete | goauth.app.permissions.read / create / update / delete |
| Role list/create/update/delete | goauth.app.roles.read / create / update / delete |
| Target role and effective-access inspection | goauth.app.roles.read |
| Role assignment and the admin user role-update route in enabled mode | goauth.app.roles.assign |
Every enabled-mode administrative service method checks its action key.
Supply ActorSessionID along with the existing actor/admin ID in direct
administrative inputs. For statistics, use
Admin.GetStats(ctx, api.GetAdminStatsInput{ActorID: id, ActorSessionID: sessionID}).
This is the single stats method in both modes. Omitting the session ID fails
when second-factor assurance is required.
An absent definition leaves the action protected-admin-only, rather than
unprotected. Auth.RequireAdmin always requires the protected admin identity.
Platform organization oversight also remains protected-admin-only; organization
owner/admin/member roles and their existing powers remain separate.
Enabled-mode invite creation and resend commit the authorization check, invite change and configured audit record before sending email. Delivery happens outside database locks. An email delivery failure leaves the committed invite or rotated code in place; use resend to retry delivery.
Only the protected admin role requires email 2FA by default, including OAuth
login. Every other role follows the non-admin policy, even with installed
library grants: password login and library actions require MFA when
RequireEmail2FA or the account's TwoFactorEnabled is on. Admin MFA settings
do not affect these roles. Non-admin OAuth follows the same global and account-level MFA
policy described in Security.
Promotion to the protected admin role does not add assurance to a live session:
administrative operations return two_factor_required until a new challenged
login succeeds. DisableAdminTwoFactor removes the admin default; global and
per-account requirements still apply. Custom operators sign in through ordinary
login; the admin login endpoint is reserved for protected administrators.
Existing /admin/users filters should use appRoleId in enabled mode; the
role filter is rejected. The existing role PATCH body uses
appRoleId, expectedRoleRevision and expectedAssignmentRevision when enabled;
its disabled-mode role: admin|user behavior is unchanged.
See the authorization route reference for methods, request bodies and the error reference for failure codes.
HTTP revision preconditions
Permission/role metadata updates, grant replacement and deletion all send the
observed resource revision through If-Match: "<revision>". This replaces both
expectedRevision JSON fields and DELETE query parameters. Missing/malformed
headers return 400; stale tags return 412 authorization_conflict. Reload and
review the current record before resubmitting. Assignment operations retain
expectedRoleRevision and expectedAssignmentRevision in JSON and use 409 for
stale assignments. Direct Go service inputs retain ExpectedRevision. See the
route reference and client wrappers.