-
Notifications
You must be signed in to change notification settings - Fork 42
Description
RFC: Personal Access Tokens (PAT)
Summary
Users need programmatic access to Frontier but service user keys are separate identities with fixed permissions. PATs let a user turn their own permissions into a scoped, expiring token.
Key design decisions:
- Two-check authorization: Every request checks PAT scope AND user permission — the token never grants more than the user has
- Role-based scoping: Users select from predefined roles (e.g.,
app_organization_manager,app_project_owner) — backend categorizes by scope and creates rolebindings accordingly - SpiceDB integration: New
app/patprincipal type with a dedicatedpat_grantedrelation onapp/organizationto isolate PAT cascading from regular grants. Schema changes validated in SpiceDB playground with assertions covering all scope combinations and cross-org isolation - Reuses existing infrastructure: Policies table,
AssignRole(), SHA3-256 hashing
Proposal
- Users can generate, view, regenerate, and revoke tokens from their account settings
- Each token is org-scoped with role-based permissions, optional project scoping (all or specific), and a required expiry
- Revocation is immediate — removes all SpiceDB relations
- All lifecycle events are audited
Design Overview
Token Properties
- Organization-scoped: Each PAT is created for a specific organization
- Role-based scopes: Users select one or more roles to define scope (e.g.,
app_organization_viewer,app_project_owner) - Optional project scoping: "All projects I have access to" or specific projects
- Required expiration: No indefinite tokens
- Format:
{config_pat_prefix}_{base64.RawURLEncoding(32-byte-random)}- Example:
fpt_x7Kj9mN2pL5qR8sT1uV4wX6yZ0aB3cD-eF_gH2iJ4k(43 chars, URL-safe, no padding)
- Example:
Two-Check Authorization
Key concept: PAT has scope, not grant. A PAT doesn't give any permission - it only restricts what the user can do through the token. The PAT scope can include app_organization_manager, but if the user isn't actually a manager, they won't get manager access.
Both checks must pass:
1. PAT Scope Check → Is this action within the PAT's configured scope?
2. User Permission Check → Does the user actually have this permission?
This ensures:
- PAT can never exceed the user's actual permissions
- If user loses access to a resource, PAT immediately loses access too
- "All projects" means "all projects I have access to (now or in the future)", not "all projects in org"
Role Selection
Users select one or more roles from predefined roles (filtered by denied_roles). The backend categorizes each role by its RoleDefinition.Scopes field and creates rolebindings accordingly:
- Org-scoped (
Scopes: [app/organization]) → rolebinding attached toorg#granted - Project-scoped (
Scopes: [app/project]) +project_idsempty → rolebinding attached toorg#pat_granted(all projects) - Project-scoped (
Scopes: [app/project]) +project_idsset → rolebinding attached to eachproject#granted
Available org-scoped roles:
app_organization_manager— org settings, projects, groups, service users (excludes billing, invitations, role/policy management). Note: includesapp_project_get+app_project_updatewhich cascade to all projects.app_organization_viewer— read-only org accessapp_organization_owneris excluded viadenied_roles— grantsapp_organization_administerwhich includes org delete
Available project-scoped roles:
app_project_owner— full project access (get/update/delete/policymanage/resourcelist + all custom resources)app_project_manager— project get/update/resourcelistapp_project_viewer— project read only, no custom resource access
Note: app_organization_manager already includes project get+update on all projects. Combining it with app_project_viewer or app_project_manager is redundant. Combining with app_project_owner is valid — it adds project delete, policymanage, and custom resource access.
Project scope:
- "All projects" (
project_idsempty) = project-scoped role granted at org level viapat_granted, cascades to all projects and their resources - "Specific projects" (
project_idsset) = role granted on each selected project viagranted
Note: Access is always limited to projects user has access to (two-check ensures this).
Extensibility: Other predefined roles (app_billing_manager, app_group_member, app_organization_accessmanager) use the same rolebinding pattern and can be enabled for PATs without schema changes — the SpiceDB app/pat:* wildcards already support them. These roles are designed as separate concerns (billing, access management, groups) and can be composed alongside org/project roles.
API Design
Create Token
rpc CreateCurrentUserPersonalToken(CreateCurrentUserPersonalTokenRequest) returns (CreateCurrentUserPersonalTokenResponse);
message CreateCurrentUserPersonalTokenRequest {
string title = 1;
string org_id = 2;
repeated string roles = 3; // e.g. ["app_organization_manager", "app_project_owner"]
repeated string project_ids = 4; // For project-scoped roles: empty = all, non-empty = specific
google.protobuf.Timestamp expires_at = 5;
}List Available Roles
Returns roles filtered by configurable denylist (excludes roles listed in denied_roles config).
rpc ListRolesForPAT(ListRolesForPATRequest) returns (ListRolesForPATResponse);List/Update/Revoke Tokens
rpc ListCurrentUserPersonalTokens(ListCurrentUserPersonalTokensRequest) returns (ListCurrentUserPersonalTokensResponse);
rpc UpdateCurrentUserPersonalToken(UpdateCurrentUserPersonalTokenRequest) returns (UpdateCurrentUserPersonalTokenResponse);
rpc RevokeCurrentUserPersonalToken(RevokeCurrentUserPersonalTokenRequest) returns (RevokeCurrentUserPersonalTokenResponse);Regenerate Token
Regenerating a token creates a new secret while preserving the same scope and metadata. This is a convenience operation that:
- Revokes the existing token (deletes policies + SpiceDB relations)
- Creates a new token with the same configuration
rpc RegenerateCurrentUserPersonalToken(RegenerateCurrentUserPersonalTokenRequest) returns (RegenerateCurrentUserPersonalTokenResponse);
message RegenerateCurrentUserPersonalTokenRequest {
string id = 1;
google.protobuf.Timestamp expires_at = 2; // Optional: update expiry
}Authentication Flow
External Services (via Gateway)
1. Client sends: Authorization: Bearer fpt_xxx...
2. Gateway calls AuthToken endpoint
3. Frontier validates PAT (hash lookup, expiry check)
4. Frontier mints JWT: { "sub": "pat_456", "sub_type": "pat", "user_id": "user_123" }
5. Upstream services use JWT for subsequent requests
Internal Frontier Auth
For Frontier's internal authentication, the PAT is validated in GetPrincipal():
- Check for
fpt_prefix in token - Validate token hash against
user_tokenstable - Return
PrincipalwithType=schema.PATPrincipal,ID=pat_id,PAT=&pat
Client Assertion Type
Add new assertion type in core/authenticate/authenticate.go:
const (
// ... existing assertions ...
// PATClientAssertion is used to authenticate using Personal Access Token
// Format: {prefix}_{base64.RawURLEncoding(32-byte-random)}
PATClientAssertion ClientAssertion = "pat"
)
var APIAssertions = []ClientAssertion{
SessionClientAssertion,
AccessTokenClientAssertion,
PATClientAssertion, // before OpaqueToken to match prefix first
OpaqueTokenClientAssertion,
JWTGrantClientAssertion,
ClientCredentialsClientAssertion,
PassthroughHeaderClientAssertion,
}Token Parsing and Validation
GetByToken(token string) validates tokens in this order:
- Validate prefix matches configured prefix (e.g.,
fpt_) - Extract and decode the base64 secret portion
- Hash the secret using SHA3-256 (same as service user tokens)
- Lookup by hash in
user_tokenstable - Check expiration
- Return PAT with
user_idfor two-check authorization
Database Schema
Migration: user_tokens Table
CREATE TABLE user_tokens (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
title text,
secret_hash text NOT NULL UNIQUE,
metadata jsonb,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
deleted_at timestamptz
);
CREATE INDEX user_tokens_user_id_idx ON user_tokens(user_id);
CREATE INDEX user_tokens_org_id_idx ON user_tokens(org_id);
CREATE INDEX user_tokens_expires_at_idx ON user_tokens(expires_at);Using Existing policies Table
PAT scopes are stored in the existing policies table using policyService.Create() with:
PrincipalType: "app/pat",PrincipalID: patIDResourceType: "app/organization"or"app/project"RoleID: the selected role (defines scope, not actual permission)Metadata: {"grant_relation": "pat_granted"}for project-scoped roles at org level (otherwise omit, defaults to"granted")
The backend loops through selected roles and creates one policy per role. Each role is categorized by its Scopes field:
- Org-scoped role → policy on org with default
granted - Project-scoped role, all projects (
project_idsempty) → policy on org withmetadata={"grant_relation": "pat_granted"} - Project-scoped role, specific projects (
project_idsset) → one policy per project with defaultgranted
This creates SpiceDB relations for scope checking via AssignRole(). The AssignRole() method reads pol.Metadata["grant_relation"] to determine which relation to use when attaching the rolebinding to the resource (relation #3). If absent, it defaults to schema.RoleGrantRelationName ("granted").
SpiceDB Schema Changes
Overview
The SpiceDB schema changes are applied automatically when the server starts via MigrateSchema(). Update the following files:
internal/bootstrap/schema/base_schema.zed- Base schema definitionsinternal/bootstrap/schema/schema.go- AddPATPrincipalconstantinternal/bootstrap/generator.go- Update dynamic relation generationcore/role/service.go- Update role-permission relation creation
1. Add app/pat Definition
In base_schema.zed:
definition app/pat {
relation user: app/user
relation org: app/organization
}
2. Add PAT as Bearer Type
In base_schema.zed, update app/rolebinding:
definition app/rolebinding {
relation bearer: app/user | app/group#member | app/serviceuser | app/pat
// ... rest unchanged
}
3. Add PAT Wildcards to Role Relations
Add app/pat:* alongside existing app/user:* and app/serviceuser:* in ALL relations in app/role definition.
In base_schema.zed:
- Update all
app_organization_*,app_project_*, andapp_group_*relations - Pattern:
relation app_xxx: app/user:* | app/serviceuser:* | app/pat:*
In generator.go (~line 177):
- Add
schema.PATPrincipalto dynamic role relation generation for custom resources
4. Add pat_granted Relation on Organization
Add a dedicated relation for PAT project-role cascading, isolated from the existing granted relation:
In base_schema.zed - Add to app/organization:
definition app/organization {
relation platform: app/platform
relation granted: app/rolebinding
relation pat_granted: app/rolebinding // NEW - exclusively for PAT org-level project roles
relation member: app/user | app/group#member | app/serviceuser
relation owner: app/user | app/serviceuser
// ...
}
5. Add Project Role Cascading for "All Projects" Scope
For project_owner at org level to cascade to all projects, add pat_granted->app_project_administer to org's synthetic permissions.
Why is this needed?
When a rolebinding with project_owner role is attached to org#pat_granted:
- The rolebinding has permission
app_project_administer - Project checks
org->project_*which traverses to org - Org's project synthetic permissions (delete, update, get, policymanage, resourcelist) must include
pat_granted->app_project_administerfor the scope to cascade
In base_schema.zed - Update org's project synthetic permissions. Each permission needs BOTH the app_project_administer catch-all (for project_owner) AND the specific permission arrow (for project_viewer and custom roles):
// synthetic permissions - project
// pat_granted mirrors granted's project arrows + adds app_project_administer catch-all
permission project_delete = platform->superuser + granted->app_organization_administer + pat_granted->app_project_administer + pat_granted->app_project_delete + granted->app_project_delete + owner
permission project_update = platform->superuser + granted->app_organization_administer + pat_granted->app_project_administer + pat_granted->app_project_update + granted->app_project_update + owner
permission project_get = platform->superuser + granted->app_organization_administer + pat_granted->app_project_administer + pat_granted->app_project_get + granted->app_project_get + owner
permission project_policymanage = platform->superuser + granted->app_organization_administer + pat_granted->app_project_administer + pat_granted->app_project_policymanage + granted->app_project_policymanage + owner
permission project_resourcelist = platform->superuser + granted->app_organization_administer + pat_granted->app_project_administer + pat_granted->app_project_resourcelist + granted->app_project_resourcelist + owner
Why both arrows? pat_granted->app_project_administer alone only works for project_owner role. The project_viewer role has app_project_get but NOT app_project_administer. Without pat_granted->app_project_get, a PAT with project_viewer at org level cannot get any project.
6. Add Custom Resource Cascading for "All Projects" Scope
For project_owner at org level to also grant access to custom resources:
In base_schema.zed: Add pat_granted->app_project_administer and pat_granted-><fqPermissionName> to custom resource permissions at org level (if any).
In generator.go (~line 141): Add both pat_granted arrows to org synthetic permission generation:
// for org
nsRel, err := aznamespace.Relation(fqPermissionName, aznamespace.Union(
aznamespace.ComputedUserset("owner"),
aznamespace.TupleToUserset("platform", "superuser"),
aznamespace.TupleToUserset("granted", "app_organization_administer"),
aznamespace.TupleToUserset("pat_granted", "app_project_administer"), // NEW - project_owner catch-all
aznamespace.TupleToUserset("pat_granted", fqPermissionName), // NEW - specific permission (e.g., resource_aoi_get)
aznamespace.TupleToUserset("granted", fqPermissionName),
), nil)This ensures both project_owner (via app_project_administer) and custom roles with specific permissions (via fqPermissionName) cascade through pat_granted.
Does this affect existing user permissions?
No. The pat_granted relation is a completely new, separate relation on app/organization. The existing granted relation and all its permission paths are unchanged. Only rolebindings explicitly attached to org#pat_granted (created by PAT service) participate in the new cascading path.
Note on role scope: The predefined app_project_owner role has Scopes: [app/project], but PAT's "all projects" feature intentionally creates org-level policies with this project-scoped role via pat_granted. The policy service does not enforce role scope against resource type during creation. This is by design for PATs — do not add scope validation to the policy creation path without accounting for this use case.
7. Migration for Existing Role Relations
Existing roles have SpiceDB tuples for app/user:* and app/serviceuser:*. For PAT support, add app/pat:* tuples.
Update core/role/service.go:
- Modify
createRolePermissionRelationto includeschema.PATPrincipalin the principals list - Modify
deleteRolePermissionRelationssimilarly
One-time migration (MigratePATRoleRelations):
- List all existing roles
- For each role's permissions, create
app/role:<roleID>#<permission>@app/pat:*relation - Ignore errors for already-existing relations
Call from MigrateRoles in bootstrap service.
SpiceDB Relation Creation
Each role in roles creates one rolebinding. The backend categorizes by Scopes and attaches to the appropriate relation.
Example 1: roles: ["app_organization_manager", "app_project_owner"], project_ids: []
Org management + full project access on all projects:
// app_organization_manager (org-scoped) → org#granted
app/rolebinding:rb1#bearer@app/pat:pat1
app/rolebinding:rb1#role@app/role:app_organization_manager
app/organization:org1#granted@app/rolebinding:rb1
// app_project_owner (project-scoped, all projects) → org#pat_granted
app/rolebinding:rb2#bearer@app/pat:pat1
app/rolebinding:rb2#role@app/role:app_project_owner
app/organization:org1#pat_granted@app/rolebinding:rb2
Example 2: roles: ["app_organization_viewer", "app_project_viewer"], project_ids: []
Org read + project read on all projects:
// app_organization_viewer (org-scoped) → org#granted
app/rolebinding:rb1#bearer@app/pat:pat1
app/rolebinding:rb1#role@app/role:app_organization_viewer
app/organization:org1#granted@app/rolebinding:rb1
// app_project_viewer (project-scoped, all projects) → org#pat_granted
app/rolebinding:rb2#bearer@app/pat:pat1
app/rolebinding:rb2#role@app/role:app_project_viewer
app/organization:org1#pat_granted@app/rolebinding:rb2
Example 3: roles: ["app_organization_viewer", "app_project_owner"], project_ids: ["proj1", "proj2"]
Org read + full project access on specific projects:
// app_organization_viewer (org-scoped) → org#granted
app/rolebinding:rb1#bearer@app/pat:pat1
app/rolebinding:rb1#role@app/role:app_organization_viewer
app/organization:org1#granted@app/rolebinding:rb1
// app_project_owner (project-scoped, specific projects) → each project#granted
app/rolebinding:rb2#bearer@app/pat:pat1
app/rolebinding:rb2#role@app/role:app_project_owner
app/project:proj1#granted@app/rolebinding:rb2
app/project:proj2#granted@app/rolebinding:rb2
Example 4: roles: ["app_organization_viewer"], project_ids: []
Org read only, no project access:
// app_organization_viewer (org-scoped) → org#granted
app/rolebinding:rb1#bearer@app/pat:pat1
app/rolebinding:rb1#role@app/role:app_organization_viewer
app/organization:org1#granted@app/rolebinding:rb1
Update/Revoke Flow
Update PAT scope: Revoke-all + recreate pattern - delete all existing policies for the PAT, then create new policies with updated scope.
Revoke PAT: Delete all policies (removes SpiceDB relations), soft delete from user_tokens table, create audit record.
Files to Create
migrations/xxx_create_user_tokens.up.sql- user_tokens tablecore/userpat/- Model (userpat.go), errors (errors.go), service (service.go)internal/store/postgres/userpat_repository.go- Repositoryinternal/api/v1beta1connect/user_pat.go- API handlers
Reusing existing services: policyService (Create/Delete/List), relationService
Files to Modify
Schema Constants
internal/bootstrap/schema/schema.go
- Add
PATPrincipal = "app/pat"constant - Add
PATGrantRelationName = "pat_granted"constant
Authentication
core/authenticate/authenticate.go
- Add
PATClientAssertionconstant and add toAPIAssertionslist - Add
PAT *userpat.PersonalAccessTokenfield toPrincipalstruct
core/authenticate/service.go
- Extend
GetPrincipal()to handlePATClientAssertion - Validate prefix, lookup by hash, check expiry, return Principal with PAT field
Implementation notes:
- Search all places checking
principal.User != nilorprincipal.ServiceUser != niland addprincipal.PAT != nilhandling where needed - Resource ownership: Custom resource
ownerrelations only acceptapp/user | app/serviceuser, notapp/pat. When a PAT principal creates a resource, the code must extract the underlyinguser_idfrom the PAT and set the owner toapp/user:<user_id>. This applies to any code path that sets theownerrelation on resources (custom resources, projects, groups, etc.). ThePrincipalstruct will carryPAT.UserIDfor this purpose.
internal/api/v1beta1connect/authenticate.go
- Support PAT in JWT minting with
sub_type=patanduser_idclaim for two-check
Authorization (Two-Check Logic)
core/resource/service.go
CheckAuthz()- Add two-check for PAT: if principal is PAT, check PAT scope then user permissionBatchCheck()- Same two-check logic for batch operations
Two-Check Implementation:
For batch permission checks with PAT, combine all checks into a single SpiceDB call:
- For N permission checks, build 2N items in one batch request
- For each check, add two items: one for PAT, one for User
- A check passes only if BOTH corresponding items pass
Example: Checking 3 permissions
- Input: [check1, check2, check3] with pat_id and user_id
- SpiceDB request: 6 items [pat:check1, user:check1, pat:check2, user:check2, pat:check3, user:check3]
- Result: check[i] passes if items[2i] AND items[2i+1] both pass
Performance: Single SpiceDB call regardless of number of checks.
Policy Service
core/policy/service.go
- Update
AssignRole()to readpol.Metadata["grant_relation"]— if set, use it as the relation name for the resource→rolebinding relation (line 129); otherwise default toschema.RoleGrantRelationName("granted")
Role Service
core/role/service.go
- Update
createRolePermissionRelationto also create PAT tuples - Update
deleteRolePermissionRelationsto also delete PAT tuples
Bootstrap Service
internal/bootstrap/service.go
- Add
MigratePATRoleRelations()function for one-time migration - Call from
MigrateRoles()or as separate bootstrap step
Generator
internal/bootstrap/generator.go
- Update role relation generation to include
PATPrincipal - Update org synthetic permission generation to include
pat_granted->app_project_administer - Add
pat_grantedrelation to the org namespace definition in the generator (for dynamically generated org permissions)
Audit Records
pkg/auditrecord/events.go
- Add PAT lifecycle event constants:
pat.created,pat.updated,pat.regenerated,pat.revoked,pat.expired
core/auditrecord/service.go
- Handle
schema.PATPrincipalin actor enrichment (lookup PAT and owning user)
Handlers & Services
internal/api/v1beta1connect/user.go, authorize.go
- Handle PAT principal type alongside user/serviceuser checks
core/organization/service.go, core/group/service.go
- Handle PAT principal where principal type is checked
Dependency Wiring
internal/api/api.go - Add UserPATService to Deps struct
internal/api/v1beta1connect/interfaces.go - Add UserPATService interface
Cleanup Cron
Background job to revoke expired PATs and their policies.
Uses existing policyService.Delete() which:
- Deletes from
policiestable - Deletes SpiceDB relations via
relationService.Delete()
For each expired PAT:
- Revoke all associated policies
- Soft delete the token record
- Create
pat.expiredaudit record
Schedule: Configurable via cleanup_interval (default: daily).
Configuration
pat:
enabled: true
token_prefix: "fpt" # Configurable prefix for PAT tokens
max_tokens_per_user_per_org: 50 # Max active tokens per user per org
max_token_lifetime: "8760h" # 1 year max
default_token_lifetime: "2160h" # 90 days default
cleanup_interval: "24h" # How often to run cleanup
denied_roles:
- "app_organization_owner"
- "app_group_owner"| Setting | Description |
|---|---|
enabled |
Enable/disable PAT creation |
token_prefix |
Prefix for generated tokens (e.g., fpt) |
max_tokens_per_user_per_org |
Max active (non-expired, non-revoked) tokens per user per org (default: 50) |
max_token_lifetime |
Maximum allowed expiration (users cannot exceed) |
default_token_lifetime |
Default if user doesn't specify |
denied_roles |
Explicit list of role names users cannot select for PAT |
Audit Logging
All PAT lifecycle events must be logged for security compliance:
| Event | Audit Record |
|---|---|
| Token created | pat.created - includes org_id, scope roles, project scope, expiry |
| Token updated | pat.updated - includes changed fields |
| Token regenerated | pat.regenerated - old token revoked, new token created |
| Token revoked | pat.revoked - includes revocation reason if provided |
| Token expired (cleanup) | pat.expired - automatic cleanup by cron |
Implementation: Use existing auditRecordRepository.Create() in PAT service methods.
Security Considerations
- Token hashing: SHA3-256, same as service user tokens
- Expiration required: No indefinite tokens
- Scope cannot exceed user permissions: Two-check ensures this
- Cross-org protection: PAT only has grants on its configured org
- Immediate revocation: Revoking PAT removes SpiceDB relations instantly
- Audit trail: All token lifecycle events are logged for compliance
Future Enhancements
- Additional role scopes: billing (
app_billing_manager), group (app_group_member), access management (app_organization_accessmanager) - Email notification after PAT creation
- Finer resource scope granularity