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/pat principal type with a dedicated pat_granted relation on app/organization to 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)
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 to org#granted
- Project-scoped (
Scopes: [app/project]) + project_ids empty → rolebinding attached to org#pat_granted (all projects)
- Project-scoped (
Scopes: [app/project]) + project_ids set → rolebinding attached to each project#granted
Available org-scoped roles:
app_organization_manager — org settings, projects, groups, service users (excludes billing, invitations, role/policy management). Note: includes app_project_get + app_project_update which cascade to all projects.
app_organization_viewer — read-only org access
app_organization_owner is excluded via denied_roles — grants app_organization_administer which 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/resourcelist
app_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_ids empty) = project-scoped role granted at org level via pat_granted, cascades to all projects and their resources
- "Specific projects" (
project_ids set) = role granted on each selected project via granted
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_tokens table
- Return
Principal with Type=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_tokens table
- Check expiration
- Return PAT with
user_id for 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: patID
ResourceType: "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_ids empty) → policy on org with metadata={"grant_relation": "pat_granted"}
- Project-scoped role, specific projects (
project_ids set) → one policy per project with default granted
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 definitions
internal/bootstrap/schema/schema.go - Add PATPrincipal constant
internal/bootstrap/generator.go - Update dynamic relation generation
core/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_*, and app_group_* relations
- Pattern:
relation app_xxx: app/user:* | app/serviceuser:* | app/pat:*
In generator.go (~line 177):
- Add
schema.PATPrincipal to 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_administer for 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
createRolePermissionRelation to include schema.PATPrincipal in the principals list
- Modify
deleteRolePermissionRelations similarly
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 table
core/userpat/ - Model (userpat.go), errors (errors.go), service (service.go)
internal/store/postgres/userpat_repository.go - Repository
internal/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
PATClientAssertion constant and add to APIAssertions list
- Add
PAT *userpat.PersonalAccessToken field to Principal struct
core/authenticate/service.go
- Extend
GetPrincipal() to handle PATClientAssertion
- Validate prefix, lookup by hash, check expiry, return Principal with PAT field
Implementation notes:
- Search all places checking
principal.User != nil or principal.ServiceUser != nil and add principal.PAT != nil handling where needed
- Resource ownership: Custom resource
owner relations only accept app/user | app/serviceuser, not app/pat. When a PAT principal creates a resource, the code must extract the underlying user_id from the PAT and set the owner to app/user:<user_id>. This applies to any code path that sets the owner relation on resources (custom resources, projects, groups, etc.). The Principal struct will carry PAT.UserID for this purpose.
internal/api/v1beta1connect/authenticate.go
- Support PAT in JWT minting with
sub_type=pat and user_id claim 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 permission
BatchCheck() - 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 read pol.Metadata["grant_relation"] — if set, use it as the relation name for the resource→rolebinding relation (line 129); otherwise default to schema.RoleGrantRelationName ("granted")
Role Service
core/role/service.go
- Update
createRolePermissionRelation to also create PAT tuples
- Update
deleteRolePermissionRelations to 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_granted relation 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.PATPrincipal in 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
policies table
- Deletes SpiceDB relations via
relationService.Delete()
For each expired PAT:
- Revoke all associated policies
- Soft delete the token record
- Create
pat.expired audit 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
References
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:
app_organization_manager,app_project_owner) — backend categorizes by scope and creates rolebindings accordinglyapp/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 isolationAssignRole(), SHA3-256 hashingProposal
Design Overview
Token Properties
app_organization_viewer,app_project_owner){config_pat_prefix}_{base64.RawURLEncoding(32-byte-random)}fpt_x7Kj9mN2pL5qR8sT1uV4wX6yZ0aB3cD-eF_gH2iJ4k(43 chars, URL-safe, no padding)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:
This ensures:
Role Selection
Users select one or more roles from predefined roles (filtered by
denied_roles). The backend categorizes each role by itsRoleDefinition.Scopesfield and creates rolebindings accordingly:Scopes: [app/organization]) → rolebinding attached toorg#grantedScopes: [app/project]) +project_idsempty → rolebinding attached toorg#pat_granted(all projects)Scopes: [app/project]) +project_idsset → rolebinding attached to eachproject#grantedAvailable 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 deleteAvailable 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 accessNote:
app_organization_manageralready includes project get+update on all projects. Combining it withapp_project_viewerorapp_project_manageris redundant. Combining withapp_project_owneris valid — it adds project delete, policymanage, and custom resource access.Project scope:
project_idsempty) = project-scoped role granted at org level viapat_granted, cascades to all projects and their resourcesproject_idsset) = role granted on each selected project viagrantedNote: 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 SpiceDBapp/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
List Available Roles
Returns roles filtered by configurable denylist (excludes roles listed in
denied_rolesconfig).List/Update/Revoke Tokens
Regenerate Token
Regenerating a token creates a new secret while preserving the same scope and metadata. This is a convenience operation that:
Authentication Flow
External Services (via Gateway)
Internal Frontier Auth
For Frontier's internal authentication, the PAT is validated in
GetPrincipal():fpt_prefix in tokenuser_tokenstablePrincipalwithType=schema.PATPrincipal,ID=pat_id,PAT=&patClient Assertion Type
Add new assertion type in
core/authenticate/authenticate.go:Token Parsing and Validation
GetByToken(token string)validates tokens in this order:fpt_)user_tokenstableuser_idfor two-check authorizationDatabase Schema
Migration:
user_tokensTableUsing Existing
policiesTablePAT scopes are stored in the existing
policiestable usingpolicyService.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
Scopesfield:grantedproject_idsempty) → policy on org withmetadata={"grant_relation": "pat_granted"}project_idsset) → one policy per project with defaultgrantedThis creates SpiceDB relations for scope checking via
AssignRole(). TheAssignRole()method readspol.Metadata["grant_relation"]to determine which relation to use when attaching the rolebinding to the resource (relation #3). If absent, it defaults toschema.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 creation1. Add
app/patDefinitionIn
base_schema.zed:2. Add PAT as Bearer Type
In
base_schema.zed, updateapp/rolebinding:3. Add PAT Wildcards to Role Relations
Add
app/pat:*alongside existingapp/user:*andapp/serviceuser:*in ALL relations inapp/roledefinition.In
base_schema.zed:app_organization_*,app_project_*, andapp_group_*relationsrelation app_xxx: app/user:* | app/serviceuser:* | app/pat:*In
generator.go(~line 177):schema.PATPrincipalto dynamic role relation generation for custom resources4. Add
pat_grantedRelation on OrganizationAdd a dedicated relation for PAT project-role cascading, isolated from the existing
grantedrelation:In
base_schema.zed- Add toapp/organization:5. Add Project Role Cascading for "All Projects" Scope
For
project_ownerat org level to cascade to all projects, addpat_granted->app_project_administerto org's synthetic permissions.Why is this needed?
When a rolebinding with
project_ownerrole is attached toorg#pat_granted:app_project_administerorg->project_*which traverses to orgpat_granted->app_project_administerfor the scope to cascadeIn
base_schema.zed- Update org's project synthetic permissions. Each permission needs BOTH theapp_project_administercatch-all (forproject_owner) AND the specific permission arrow (forproject_viewerand custom roles):Why both arrows?
pat_granted->app_project_administeralone only works forproject_ownerrole. Theproject_viewerrole hasapp_project_getbut NOTapp_project_administer. Withoutpat_granted->app_project_get, a PAT withproject_viewerat org level cannot get any project.6. Add Custom Resource Cascading for "All Projects" Scope
For
project_ownerat org level to also grant access to custom resources:In
base_schema.zed: Addpat_granted->app_project_administerandpat_granted-><fqPermissionName>to custom resource permissions at org level (if any).In
generator.go(~line 141): Add bothpat_grantedarrows to org synthetic permission generation:This ensures both
project_owner(viaapp_project_administer) and custom roles with specific permissions (viafqPermissionName) cascade throughpat_granted.Does this affect existing user permissions?
No. The
pat_grantedrelation is a completely new, separate relation onapp/organization. The existinggrantedrelation and all its permission paths are unchanged. Only rolebindings explicitly attached toorg#pat_granted(created by PAT service) participate in the new cascading path.Note on role scope: The predefined
app_project_ownerrole hasScopes: [app/project], but PAT's "all projects" feature intentionally creates org-level policies with this project-scoped role viapat_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:*andapp/serviceuser:*. For PAT support, addapp/pat:*tuples.Update
core/role/service.go:createRolePermissionRelationto includeschema.PATPrincipalin the principals listdeleteRolePermissionRelationssimilarlyOne-time migration (
MigratePATRoleRelations):app/role:<roleID>#<permission>@app/pat:*relationCall from
MigrateRolesin bootstrap service.SpiceDB Relation Creation
Each role in
rolescreates one rolebinding. The backend categorizes byScopesand attaches to the appropriate relation.Example 1:
roles: ["app_organization_manager", "app_project_owner"], project_ids: []Org management + full project access on all projects:
Example 2:
roles: ["app_organization_viewer", "app_project_viewer"], project_ids: []Org read + project read on all projects:
Example 3:
roles: ["app_organization_viewer", "app_project_owner"], project_ids: ["proj1", "proj2"]Org read + full project access on specific projects:
Example 4:
roles: ["app_organization_viewer"], project_ids: []Org read only, no project access:
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_tokenstable, 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 handlersReusing existing services:
policyService(Create/Delete/List),relationServiceFiles to Modify
Schema Constants
internal/bootstrap/schema/schema.goPATPrincipal = "app/pat"constantPATGrantRelationName = "pat_granted"constantAuthentication
core/authenticate/authenticate.goPATClientAssertionconstant and add toAPIAssertionslistPAT *userpat.PersonalAccessTokenfield toPrincipalstructcore/authenticate/service.goGetPrincipal()to handlePATClientAssertionImplementation notes:
principal.User != nilorprincipal.ServiceUser != niland addprincipal.PAT != nilhandling where neededownerrelations 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.gosub_type=patanduser_idclaim for two-checkAuthorization (Two-Check Logic)
core/resource/service.goCheckAuthz()- Add two-check for PAT: if principal is PAT, check PAT scope then user permissionBatchCheck()- Same two-check logic for batch operationsTwo-Check Implementation:
For batch permission checks with PAT, combine all checks into a single SpiceDB call:
Example: Checking 3 permissions
Performance: Single SpiceDB call regardless of number of checks.
Policy Service
core/policy/service.goAssignRole()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.gocreateRolePermissionRelationto also create PAT tuplesdeleteRolePermissionRelationsto also delete PAT tuplesBootstrap Service
internal/bootstrap/service.goMigratePATRoleRelations()function for one-time migrationMigrateRoles()or as separate bootstrap stepGenerator
internal/bootstrap/generator.goPATPrincipalpat_granted->app_project_administerpat_grantedrelation to the org namespace definition in the generator (for dynamically generated org permissions)Audit Records
pkg/auditrecord/events.gopat.created,pat.updated,pat.regenerated,pat.revoked,pat.expiredcore/auditrecord/service.goschema.PATPrincipalin actor enrichment (lookup PAT and owning user)Handlers & Services
internal/api/v1beta1connect/user.go,authorize.gocore/organization/service.go,core/group/service.goDependency Wiring
internal/api/api.go- AddUserPATServicetoDepsstructinternal/api/v1beta1connect/interfaces.go- AddUserPATServiceinterfaceCleanup Cron
Background job to revoke expired PATs and their policies.
Uses existing
policyService.Delete()which:policiestablerelationService.Delete()For each expired PAT:
pat.expiredaudit recordSchedule: Configurable via
cleanup_interval(default: daily).Configuration
enabledtoken_prefixfpt)max_tokens_per_user_per_orgmax_token_lifetimedefault_token_lifetimedenied_rolesAudit Logging
All PAT lifecycle events must be logged for security compliance:
pat.created- includes org_id, scope roles, project scope, expirypat.updated- includes changed fieldspat.regenerated- old token revoked, new token createdpat.revoked- includes revocation reason if providedpat.expired- automatic cleanup by cronImplementation: Use existing
auditRecordRepository.Create()in PAT service methods.Security Considerations
Future Enhancements
app_billing_manager), group (app_group_member), access management (app_organization_accessmanager)References