Authorization in the Colden kit

How the API decides who may call an endpoint: authentication schemes, the current user, the policies and who passes each, the permission matrix, the owner rules, the step-up for super-admins and how a new endpoint declares its policy.

Every endpoint of the API declares who may call it, and one test fails the build when an endpoint does not. Authentication says who the caller is; the policies below say what they may do; the rules about owners say which member changes are allowed.

Who the caller is

  • A person presents the identity server's session (X-Session-Token header or the ory_kratos_session cookie). The Session scheme confirms it with Kratos (Colden.Auth.Ory), then the API resolves the person's user row and memberships once per request into ICurrentUser, from the cache after the first request. A person the identity server's hooks have not created yet is created then.
  • The identity server calls the internal endpoints with its key (IdentityWebhook scheme), on the internal port only.
  • A program presents an organization's API key in the X-Api-Key header (ApiKey scheme). It acts as a service identity of the key's organization with the key's scopes, on the endpoints that allow keys; see API keys.

Policies

PolicyWho passes
authenticatedAny signed-in person. It is also the fallback for an endpoint that declares nothing.
organization-memberA member of the request's organization, with any role; or an API key of that organization, on an endpoint that allows keys with a scope the key carries.
organization-adminAn admin or owner of the request's organization.
organization-ownerAn owner of the request's organization.
super-adminA platform operator (User.IsSuperAdmin) whose session used a second factor.
identity-serverThe identity server, with the web hook key.
ApiKeyScopeRequirement(scope)An API key that was granted the scope.

The request's organization is the tenant context, or the organization the route names ({organizationId}), which always wins over the X-Tenant-Id header, or that header when the route names none. A super-admin has no role in an organization they are not a member of; they read it in the super-admin area and act in it by impersonation, both audited.

Permission matrix

PermissionMemberAdminOwner
See the organization and use the product (organization:read)yesyesyes
See the members (members:read)yesyesyes
Change the organization's name, logo and settings (organization:update)noyesyes
Invite, remove and change the role of admins and members (members:manage)noyesyes
Create, rename and revoke API keys (api-keys:manage)noyesyes
Read the audit log (audit:read)noyesyes
Change the plan, payment method and billing details (billing:manage)nonoyes
Transfer ownership (ownership:transfer)nonoyes
Delete the organization (organization:delete)nonoyes

Colden.Auth.Permissions holds this table; an endpoint's policy is the lowest role of the permission it needs.

Owner rules

OwnerRules decides every change to an organization's members, in one place:

  • An organization always has at least one owner: its last owner cannot leave, be demoted or be removed.
  • Ownership is given only by an explicit transfer, which only an owner makes; afterwards the receiver is an owner and the giver an admin. A role change to owner answers "use the transfer".
  • Owners manage everyone, including other owners.
  • Admins manage admins and members, never owners.
  • Members manage nobody; anyone may leave, except the last owner.

The API answers a change the rules refuse with 409 and the problem type last-owner or ownership-transfer-required, and with 403 when the role does not allow it.

Super-admins and the second factor

The super-admin policy requires a session at assurance level aal2. A super-admin whose session used one factor receives 403 with the problem type https://colden.dev/problems/step-up-required, which tells the web to ask for the second factor and retry; anyone else receives the plain 403. Auth:SuperAdminRequiresMfa=false drops the second factor; keep it on outside local development.

The super-admin area

/v1/admin is the platform's operators' area, behind the super-admin policy. Each request opens a cross-tenant scope, logged at warning level with the super-admin and the reason (SuperAdminTenantAccess):

EndpointDoes
GET /v1/admin/tenants?search=&status=&page=&pageSize=The organizations, newest first: status, members, the current plan and status, and the monthly revenue (a yearly price divided by twelve, nothing during a trial).
GET /v1/admin/tenants/{tenantId}One organization, suspended or not, with its subscription, members and last twenty audit entries.
POST /v1/admin/tenants/{tenantId}/suspend and /reinstate with { "reason": "..." }Suspends an organization (its members get 403 organization-suspended) or reinstates it; recorded as organization.suspended and organization.reinstated.
POST /v1/admin/tenants/{tenantId}/trial/extend with { "days": 7, "reason": "..." }Adds 1 to 90 days to the trial in Stripe and in the mirror; recorded as subscription.trial_extended.

The feature flags are managed under /v1/admin/feature-flags (feature flags). A person becomes a super-admin with the make-superadmin <email> command of the API binary (getting started).

Impersonation

A super-admin acts as a member of one organization for thirty minutes, to see what they see:

  1. POST /v1/admin/impersonations with { "userId": "...", "organizationId": "...", "reason": "ticket 42" } starts it and returns token and expiresAt. The person must be a member of the organization and not a super-admin. The start is recorded as impersonation.started in the organization's log.
  2. Each request that carries the token in X-Impersonation, with the super-admin's own session at aal2, runs as the member: in that organization only, without the super-admin's rights. Every write records the member as the actor and the super-admin as the impersonator. The web keeps the token in a signed cookie and forwards it: the tenant detail's "Impersonate" posts to the web's /api/impersonation, which starts the impersonation, sets the cookie and opens the organization; the banner under the top bar names the member and stops it. The super-admin area asks to stop a running impersonation before it opens, since the API would answer as the member.
  3. DELETE /v1/impersonation, sent with the token, ends it (impersonation.ended). The token stops working at once.

The token names an impersonation session in impersonation_sessions, protected with the hosts' shared key ring. A token that is ended, expired, altered or presented by another super-admin is refused with 403 impersonation-ended.

API keys

Admins and owners manage the organization's keys under /v1/organizations/{organizationId}/api-keys: list them (GET), see the scopes a key can be granted (GET .../scopes), create one with a name, its scopes and an optional expiry (POST), rename it (PATCH .../{keyId}) and revoke it (DELETE .../{keyId}). The creation answers with the key once, in secret: ck_, a public identifier of 16 hexadecimal characters, an underscore and a random secret. The kit stores the SHA-256 hash of the secret only; the list shows the key's prefix (ck_ and the identifier), its scopes, its status (active, expired or revoked), who created it and when it was last used. Each change is recorded in the audit log as api_key.created or api_key.updated, never with the hash.

A request with X-Api-Key is authenticated by the key, whatever else it carries: the identifier finds the key, the secret must hash to the stored hash (compared in constant time), and the key must not be revoked or expired; otherwise the answer is 401. The key then passes only the member-level policy, only on an endpoint that declares .AllowApiKey(scope) with a scope the key carries, and only in its own organization; everything else answers 403 (an organization endpoint) or 401 (any other). The kit's endpoints that allow keys:

EndpointScope
GET /v1/organizations/{organizationId}organization:read
GET /v1/organizations/{organizationId}/membersmembers:read
GET /v1/organizations/{organizationId}/billing/usageusage:read

A product declares its own scopes in ApiKeys:Scopes and lets keys call its endpoints with .AllowApiKey("projects:write"); a key can be granted only the kit's and the product's scopes. A key's last use is written at most once per ApiKeys:LastUsedPrecisionSeconds (300), and the rate limit counts each key on its own (RateLimiting:ApiKey). A request made with a key has no person behind it, so what it changes is recorded without an actor.

Declaring a policy on an endpoint

An organization's endpoints live in the organization route group, which requires organization-member and runs TenantContextEndpointFilter: the caller must be a member (or an API key) of the organization in the route, the organization must exist and not be suspended (organization-suspended, 403; deleted is 404), and only then is the tenant context set. A stricter policy goes on the endpoint:

var members = endpoints.MapOrganizationGroup("/members");

members.MapGet("/", ListMembers.HandleAsync);
members.MapPatch("/{userId:guid}", ChangeMemberRole.HandleAsync).RequireAuthorization(PolicyNames.OrganizationAdmin);

An endpoint anyone may call says so with .AllowAnonymous() (the health endpoints and the OpenAPI document do). An endpoint with an {organizationId} route value must require an organization policy or super-admin.

Tests

apps/api/tests/Colden.Api.Tests/Authorization/ proves the rules: every mapped endpoint declares a policy or allows anonymous access; every organization endpoint requires an organization role; each persona (anonymous, a member of another organization, member, admin, owner, super-admin at aal1 and at aal2) passes exactly the policies above; the step-up problem type; API key scopes; the owner rules as a matrix. Slice tests sign in with TestUserBuilder and the test session scheme (see docs/guides/testing.md).