pkg/cmd/roachprod-centralized/docs/services/AUTH.md
This document provides an implementation guide for the authentication and authorization system in roachprod-centralized.
Related Documentation:
The authentication system uses opaque bearer tokens for all API authentication, with two primary flows:
Key design decisions:
The API supports three authentication implementations, configured via AUTH_TYPE:
| Implementation | Config Value | Use Case | Description |
|---|---|---|---|
| Bearer | bearer | Production | Opaque tokens with database-backed users, groups, and permissions |
| JWT | jwt | Google IAP | Google ID tokens validated via OIDC; grants wildcard permissions |
| Disabled | AUTH_DISABLED=true | Development | Bypasses authentication; always returns admin principal |
Not all controllers are available in every authentication mode. Controllers that manage database-backed entities (users, groups, tokens, service accounts) are only available with bearer authentication:
| Controller | Bearer | JWT / Disabled | Description |
|---|---|---|---|
health | ✓ | ✓ | Health checks |
clusters | ✓ | ✓ | Cluster management |
public-dns | ✓ | ✓ | DNS synchronization |
tasks | ✓ | ✓ | Background task management |
auth (WhoAmI) | ✓ | ✓ | Returns current principal info |
auth (Bearer) | ✓ | ✗ | Okta token exchange, self-service token management |
service-accounts | ✓ | ✗ | Service account CRUD and token minting |
admin | ✓ | ✗ | User and group administration |
scim | ✓ | ✗ | SCIM 2.0 user/group provisioning |
Why are some controllers disabled?
With jwt or disabled authentication:
The bearer authenticator validates opaque tokens against the database:
api_tokens tableservice_account_permissions (orphan SAs)Enabled endpoints:
POST /v1/auth/okta/exchange - Exchange Okta ID token for opaque bearer tokenGET /v1/auth/tokens - List own tokensDELETE /v1/auth/tokens/:id - Revoke own tokenThe JWT authenticator validates Google ID tokens:
idtoken library*) permissionsUse case: When the API runs behind Google Cloud Identity-Aware Proxy (IAP).
Limitations:
The disabled authenticator bypasses all authentication:
Use case: Local development and testing only. Never use in production.
CLI Okta Backend
| | |
|-- Device Flow ---->| |
|<-- ID Token -------| |
| | |
|----- POST /api/v1/auth/okta/exchange ---->|
| | Validate token |
| | Lookup user |
| | Issue opaque token
|<------------ rp$user$1$... ---------------|
The exchange endpoint validates the Okta token using OIDC discovery (.well-known/openid-configuration), looks up the user by okta_user_id, and issues an opaque token.
Entry point: controllers/auth/controller.go OktaExchange()
Administrators create service accounts, assign permissions, and mint tokens:
// 1. Create SA
POST /api/v1/service-accounts
// 2. Assign permissions
POST /api/v1/service-accounts/:id/permissions
// 3. Optional: Add IP allowlist
POST /api/v1/service-accounts/:id/origins
// 4. Mint token (returned once, stored as hash)
POST /api/v1/service-accounts/:id/tokens
Entry point: controllers/service-accounts/service_accounts.go
Service accounts come in two types, determined by the delegated_from field:
| Type | delegated_from | Permission Source | Use Case |
|---|---|---|---|
| Orphan | NULL | service_account_permissions table | CI/CD automation, monitoring, SCIM provisioning |
| Delegated | User UUID | Inherited from user's group permissions | User-created automation that acts on their behalf |
Delegated SAs inherit permissions from a user principal. They have no entries in service_account_permissions; instead, their permissions are resolved at authentication time from the delegated user's group memberships.
When to use: When a user wants to create automation that acts with their own permissions. The SA automatically gains/loses permissions as the user's group memberships change.
Creating a delegated SA:
# Omit "orphan" field or set to false (default behavior)
curl -X POST /api/v1/service-accounts \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"name": "my-automation", "description": "Runs with my permissions"}'
# Response includes delegated_from = creating user's ID
{
"id": "...",
"name": "my-automation",
"delegated_from": "11111111-1111-1111-1111-111111111111", // User's ID
...
}
Permission resolution:
-- Delegated SA: permissions come from the delegated user's groups
SELECT gp.permission FROM group_permissions gp
JOIN groups g ON g.display_name = gp.group_name
JOIN group_members gm ON gm.group_id = g.id
WHERE gm.user_id = :delegated_from;
Characteristics:
delegated_from = <user_uuid> (the user whose permissions are inherited)delegated_from user)Orphan SAs have their own explicit permissions stored in the service_account_permissions table. They are independent entities that don't inherit from any user.
When to use: For system-level automation that needs specific, controlled permissions independent of any user. Examples: SCIM provisioning, CI/CD pipelines, monitoring systems.
Creating an orphan SA:
# Set "orphan": true explicitly
curl -X POST /api/v1/service-accounts \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"name": "ci-automation", "description": "CI pipeline", "orphan": true}'
# Response has no delegated_from field
{
"id": "...",
"name": "ci-automation",
...
}
# Then grant explicit permissions
curl -X POST /api/v1/service-accounts/:id/permissions \
-d '{"scope": "gcp-my-project", "permission": "clusters:create"}'
Permission resolution:
-- Orphan SA: permissions come from service_account_permissions
SELECT permission FROM service_account_permissions
WHERE service_account_id = :sa_id;
Characteristics:
delegated_from = NULLPOST /service-accounts/:id/permissions| Creator Principal | Can Create Orphan? | Can Create Delegated? | Delegated From |
|---|---|---|---|
| User | Yes | Yes | Creator's user ID |
| Delegated SA | Yes | Yes | SA's delegated_from user ID |
| Orphan SA | Yes | No (403 Forbidden) | N/A |
The restriction on orphan SAs creating delegated SAs prevents privilege escalation—orphan SAs have no user context to delegate from.
| SA Type | Add/Remove Permissions | Reason |
|---|---|---|
| Orphan | Allowed | Permissions are explicit |
| Delegated | Forbidden (403) | Permissions are inherited from user |
Error codes:
ErrSACreationNotAllowedFromOrphanSA: Orphan SA tried to create a delegated SAErrNonOrphanSAPermissionModification: Attempted to modify permissions on a delegated SA┌─────────────────────────────────────────────────────────────────┐
│ Controllers │
│ auth/ │ service-accounts/ │ admin/ │ scim/ │
└────┬────┴──────────┬──────────┴────┬─────┴────┬─────────────────┘
│ │ │ │
└───────────────┴───────┬───────┴──────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Auth Middleware │
│ authn_authz.go: AuthMiddleware() + AuthzMiddleware() │
│ ─ Extracts token from Authorization header │
│ ─ Calls authenticator.Authenticate() │
│ ─ Checks authorization requirements │
│ ─ Stores Principal in context │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ IAuthenticator Interface │
│ auth/interface.go │
│ ├─ bearer/bearer.go (production - opaque tokens) │
│ ├─ jwt/jwt.go (Google IAP integration) │
│ └─ disabled/disabled.go (development bypass) │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Auth Service │
│ services/auth/service.go │
│ ├─ authenticate.go (token validation, principal loading) │
│ ├─ tokens.go (token CRUD, revocation) │
│ ├─ users.go (user management) │
│ ├─ groups.go (group management) │
│ ├─ service_accounts.go (SA management) │
│ └─ okta.go (Okta token validation) │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Auth Repository │
│ repositories/auth/repository.go (interface) │
│ repositories/auth/cockroachdb/ (implementation) │
└─────────────────────────────────────────────────────────────────┘
auth/interface.go)The Principal struct represents an authenticated user or service account:
type Principal struct {
Token TokenInfo // Token metadata (ID, type, expiry)
UserID *uuid.UUID // Set for user tokens
ServiceAccountID *uuid.UUID // Set for SA tokens
User *authmodels.User // Full user (bearer auth)
ServiceAccount *authmodels.ServiceAccount
Permissions []authmodels.Permission // Resolved permissions
Claims map[string]interface{} // JWT claims (if JWT auth)
}
auth/interface.go)All authenticators implement this interface:
type IAuthenticator interface {
// Authenticate validates token and returns Principal
Authenticate(ctx context.Context, token string, clientIP string) (*Principal, error)
// Authorize checks if Principal has required permissions
Authorize(ctx context.Context, principal *Principal, req *AuthorizationRequirement, endpoint string) error
}
auth/interface.go)Defines what permissions are needed for an endpoint:
type AuthorizationRequirement struct {
RequiredPermissions []string // ALL required (AND logic)
AnyOf []string // ANY required (OR logic)
}
auth/bearer/bearer.go)The production authenticator that:
authService.AuthenticateToken() to validate tokenTokens use a structured format: rp$<type>$<version>$<random>
| Component | Description |
|---|---|
rp | Application prefix |
<type> | user or sa |
<version> | Format version (1) |
<random> | 43 chars of base62 entropy |
Token suffix (rp$user$1$****<last8>) is stored for audit logging.
Permissions follow: <service>:<resource>:<action>:<scope>
Auth service permissions:
auth:scim:manage-user
auth:service-accounts:create
auth:service-accounts:view:all
auth:service-accounts:view:own
auth:tokens:view:all
auth:tokens:revoke:own
// ... defined in services/auth/types/types.go
Cluster service permissions:
clusters:create
clusters:view:all
clusters:view:own
// ... defined in services/clusters/types/types.go
For users (in services/auth/authenticate.go):
users tablegroup_members → groups → group_permissionsFor orphan service accounts (delegated_from = NULL):
service_accounts tableservice_account_permissions tableFor delegated service accounts (delegated_from = user_id):
service_accounts table// Simple check (any scope)
if principal.HasPermission("clusters:create") { ... }
// Scoped check (specific scope/environment)
if principal.HasPermissionScoped("clusters:create", "gcp-my-project") { ... }
// Any of multiple permissions (OR)
if principal.HasAnyPermission([]string{"clusters:view:all", "clusters:view:own"}) { ... }
Authorization is intentionally split across two layers:
AuthorizationRequirement.HasPermissionScoped(...).clusters:create, clusters:view:all|own, clusters:update:all|own.Permissions are declared on controller handlers:
&controllers.ControllerHandler{
Method: "POST",
Path: "/api/v1/clusters",
Func: ctrl.Create,
Authorization: &auth.AuthorizationRequirement{
AnyOf: []string{
clustermodels.PermissionCreate,
},
},
}
controllers/scim/)Implements SCIM 2.0 for Users and Groups:
| Endpoint | Controller Method |
|---|---|
GET/POST/PUT/PATCH/DELETE /scim/v2/Users | scim.go |
GET/POST/PUT/PATCH/DELETE /scim/v2/Groups | groups.go |
GET /scim/v2/ServiceProviderConfig | Discovery |
GET /scim/v2/Schemas | Discovery |
GET /scim/v2/ResourceTypes | Discovery |
| SCIM Event | Backend Action |
|---|---|
| User created | Insert into users, active=true |
| User deactivated | Set active=false, revoke all tokens |
| User reactivated | Set active=true |
| User deleted | Hard delete from users |
Groups are mapped to permissions via group_permissions table:
-- Okta group "Division-Engineering" gets clusters:create for all GCP projects
INSERT INTO group_permissions (group_name, scope, permission)
VALUES ('Division-Engineering', 'gcp-engineering', 'clusters:create');
The group_name column matches the display_name of groups in the groups table.
Define constant in service's types/types.go:
const PermissionNewAction = TaskServiceName + ":new-action"
Add to controller handler's Authorization:
Authorization: &auth.AuthorizationRequirement{
AnyOf: []string{PermissionNewAction},
},
Add group permission mapping in database (if users need it)
IAuthenticator interface in auth/<type>/<type>.goAuthenticationType enum if adding new typecontrollers/scim/| Variable | Description | Default |
|---|---|---|
AUTH_DISABLED | Disable auth (dev only) | false |
AUTH_TYPE | bearer or jwt | bearer |
OKTA_ISSUER | Okta issuer URL | Required |
OKTA_AUDIENCE | Okta audience | Required |
AUTH_TOKEN_TTL | Default token TTL | 168h |
AUTH_CLEANUP_INTERVAL | Expired token cleanup | 24h |
BOOTSTRAP_SCIM_TOKEN | Bootstrap token for initial SCIM provisioning | (empty) |
When deploying with bearer authentication, you need a way to bootstrap the first service account for SCIM provisioning. The chicken-and-egg problem: you need a service account token to call the SCIM API, but you can't create service accounts without first provisioning users.
The bootstrap token solves this by allowing you to pre-configure a token that creates an initial SCIM service account on first startup:
rp$sa$1$<43-chars-base62-entropy>ROACHPROD_BOOTSTRAP_SCIM_TOKEN=<token>The bootstrap token must:
rp$sa$1$ (service account token, version 1)Generate a valid token:
# Using openssl to generate entropy
TOKEN="rp\$sa\$1\$$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 43)"
echo "$TOKEN"
The bootstrap service account is created as an orphan SA with these permissions:
auth:scim:manage-user - Manage users and groups via SCIMauth:service-accounts:create - Create additional service accountsauth:service-accounts:view:all - View any service accountauth:service-accounts:update:all - Update any service accountauth:service-accounts:delete:all - Delete any service accountauth:service-accounts:mint:all - Mint tokens for any service accountauth:tokens:view:all - View all tokensauth:tokens:revoke:own - Revoke own tokensbootstrap: true flag# 1. Generate bootstrap token
export BOOTSTRAP_TOKEN="rp\$sa\$1\$$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 43)"
# 2. Start the service with bootstrap token
export ROACHPROD_BOOTSTRAP_SCIM_TOKEN="$BOOTSTRAP_TOKEN"
export ROACHPROD_API_AUTHENTICATION_TYPE=bearer
export ROACHPROD_API_AUTHENTICATION_BEARER_OKTA_ISSUER="https://your-org.okta.com"
roachprod-centralized api
# 3. Configure Okta SCIM with the bootstrap token
# - Add SCIM app in Okta
# - Set API endpoint: https://your-api/scim/v2
# - Set Bearer token: $BOOTSTRAP_TOKEN
# - Enable user/group provisioning
# 4. (Optional) Mint a permanent token for SCIM and revoke bootstrap
# The bootstrap token expires in 6 hours anyway, but you can:
# - Create a new long-lived SCIM service account
# - Update Okta SCIM with the new token
Token format error:
Error: bootstrap SCIM token must start with prefix "rp$sa$1$"
Ensure the token format is correct. Common issues:
\$ or single quotesToken entropy error:
Error: bootstrap SCIM token must have at least 43 characters of entropy
The random portion after rp$sa$1$ must be at least 43 characters.
Bootstrap skipped:
Log: service accounts already exist, skipping bootstrap
This is expected on subsequent startups. The bootstrap only runs once when no SAs exist.
For database schema details, see the migrations in repositories/auth/cockroachdb/migrations_definition.go