Skip to content

# RFC: Personal Access Tokens (PAT) #1372

@AmanGIT07

Description

@AmanGIT07

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:

  1. Revokes the existing token (deletes policies + SpiceDB relations)
  2. 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():

  1. Check for fpt_ prefix in token
  2. Validate token hash against user_tokens table
  3. 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:

  1. Validate prefix matches configured prefix (e.g., fpt_)
  2. Extract and decode the base64 secret portion
  3. Hash the secret using SHA3-256 (same as service user tokens)
  4. Lookup by hash in user_tokens table
  5. Check expiration
  6. 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:

  1. internal/bootstrap/schema/base_schema.zed - Base schema definitions
  2. internal/bootstrap/schema/schema.go - Add PATPrincipal constant
  3. internal/bootstrap/generator.go - Update dynamic relation generation
  4. 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):

  1. List all existing roles
  2. For each role's permissions, create app/role:<roleID>#<permission>@app/pat:* relation
  3. 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:

  1. For N permission checks, build 2N items in one batch request
  2. For each check, add two items: one for PAT, one for User
  3. 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:

  1. Deletes from policies table
  2. Deletes SpiceDB relations via relationService.Delete()

For each expired PAT:

  1. Revoke all associated policies
  2. Soft delete the token record
  3. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions