Authentication & Authorization
Velocity supports multiple authentication strategies (JWT, OIDC, API key, Composite) and enforces access control through 7 layers.
Authentication Strategies
AuthStrategy CRD
apiVersion: velocity.sh/v1kind: AuthStrategymetadata: name: jwt-internal namespace: platformspec: type: jwt # jwt | oidc | api_key | composite displayName: "Internal JWT"
jwt: issuer: https://auth.acme.com audience: acme-api
# Multiple issuers issuers: - issuer: https://auth.acme.com audience: acme-api - issuer: https://idp.partners.com audience: acme-partner-api
jwksUrl: https://auth.acme.com/.well-known/jwks.json claimMapping: actorId: sub roles: - path: realm_access.roles transform: identity attributes: region: path: custom_claims.region transform: identity store_id: path: custom_claims.store_id transform: identity
revocation: failOpen: false # Default deny when Redis unavailable
oidc: clientId: velocity-app clientSecret: <secret-ref> discoveryUrl: https://idp.example.com/.well-known/openid-configuration redirectUri: https://api.velocity.acme.com/auth/callback scopes: [openid, profile, email] claimMapping: actorId: sub roles: - path: groups transform: identity
sessionTtl: 8h sessionSecure: true
apiKey: headerName: X-API-Key ipAllowlist: - 10.0.0.0/8 - 192.168.0.0/16 revocation: failOpen: falseJWT (Most Common)
# Send JWT in Authorization headercurl -H "Authorization: Bearer eyJhbGc..." \ https://api.velocity.acme.com/api/acme/supply-chain/procurement/purchase-order/v1The API server:
- Extracts the token
- Verifies signature against JWKS (cached, refreshed every 5 minutes)
- Validates expiration and audience
- Maps claims to
Identity{actor_id, roles, attributes} - Sets session context in Postgres
OIDC (Browser-based)
# 1. User visits https://api.velocity.acme.com/auth/login?next=/orders# 2. API redirects to IdP# 3. User authenticates and consents# 4. IdP redirects back to /auth/callback with authorization code# 5. API exchanges code for token# 6. Session cookie set; user redirected to /ordersAPI Key
velocity api-key create --name prod --ttl 30d# Output: vel_prod_abc123...
# Use it:curl -H "X-API-Key: vel_prod_abc123..." \ https://api.velocity.acme.com/api/acme/supply-chain/procurement/purchase-order/v1API keys are:
- SHA256 hashed in the database (plaintext shown once at creation)
- IP-restricted (optional)
- Time-expiring (configurable TTL)
- Revocable instantly
Composite (Try Multiple Strategies)
type: compositestrategies: - name: jwt-internal weight: 1 - name: jwt-partners weight: 2 - name: api-key weight: 3 # First successful authentication winsAccess Control Layers (7 Layers)
Layer 1: Route-Level RBAC
Does the actor have a role that allows this operation?
apiVersion: velocity.sh/v1kind: SchemaDefinitionspec: access: roles: create: [procurement-writer] read: [procurement-reader, procurement-writer] update: [procurement-writer] delete: [procurement-admin]If you lack a required role, you get 403 Forbidden before any data is touched.
Layer 2: ABAC (Attribute-Based Access Control)
CEL expressions evaluated at request time:
spec: access: abac: - operation: create condition: "actor.department in ['procurement', 'finance']" message: "Only procurement and finance can create POs"
- operation: read condition: "actor.tenure_days > 30 || actor.is_manager" message: "New employees cannot read POs until 30-day review"Layer 3: Cross-Schema RBAC
Do you have read access to a schema you want to join?
Currently deferred to Phase 5 (when query joins are implemented). The gate will be: before adding a join, verify actor has read permission on the target schema.
Layer 4: Row Filter
Scope rows by attribute:
spec: access: rowFilter: - role: region-manager condition: "region = current_setting('app.current_region')" message: "Region managers see only their region"
- role: store-manager condition: "store_id = ANY(current_setting('app.store_ids')::text[])"A request sees only rows matching its filter.
Layer 5: Field Filter (Read)
Hide sensitive fields on response:
spec: access: fieldAccess: - field: cost_basis read: [finance-reader, finance-admin]Actors without cost_basis read permission see null for that field.
Layer 6: Field Filter (Write)
Reject payloads containing fields the actor can’t write:
spec: access: fieldAccess: - field: approved_by write: [procurement-admin]If an actor without approved_by write permission includes it in PATCH, the request fails 403.
Layer 7: Postgres RLS
The database itself enforces row-level security:
CREATE POLICY region_policyON purchase_order_v1FOR ALLUSING (region = current_setting('app.current_region'))Even if the app has a bug and returns all rows, RLS filters them.
RoleBinding
Grant roles to actors:
apiVersion: velocity.sh/v1kind: RoleBindingmetadata: name: ravi.kumar-procurement namespace: acme-supply-chain-procurementspec: actor: ravi.kumar roles: [procurement-reader, procurement-writer] expiryDate: "2027-12-31T23:59:59Z" scope: region: west store_ids: [10, 20, 30] attributes: department: procurement tenure_days: 365Create via CLI:
velocity grant \ --actor ravi.kumar \ --roles procurement-reader,procurement-writer \ --schema acme/supply-chain/procurement/purchase-order/v1 \ --scope region=west,store_ids=10:20:30 \ --expires 2027-12-31Revoke:
velocity revoke --actor ravi.kumar \ --schema acme/supply-chain/procurement/purchase-order/v1RoleBindings are stored in Postgres and cached in Redis for revocation checks.
Claim Mapping
Transform JWT claims into Velocity identity attributes:
spec: jwt: claimMapping: actorId: sub # Required; identifies the actor
roles: - path: realm_access.roles transform: identity
- path: groups transform: prefix_strip prefix: "acme-"
attributes: region: path: custom.region transform: identity
store_ids: path: custom.stores transform: split separator: ","
department: path: custom.dept transform: lookup # Look up in Postgres table table: platform.department_mapping keyColumn: jwt_value valueColumn: dept_nameTransforms
identity: Use value as-isprefix_strip: Remove prefix (e.g., “acme-” → [“admin”, “reader”])split: Split by separator (e.g., “a,b,c” → [“a”, “b”, “c”])uppercase/lowercase: Case transformationlookup: Join with a Postgres table (e.g., JWT email → internal user ID)regex_extract: Extract with regex groupsstatic_append: Append a static value
Fail-Mode Matrix (ADR-003)
When external dependencies fail, the system defaults to deny (fail-closed):
Redis Unavailable (Revocation Check)
| Scenario | Default | Override |
|---|---|---|
| Redis unreachable | 503 REVOCATION_UNAVAILABLE (deny) | failOpen: true (dangerous) |
JWKS Endpoint Unavailable (Issuer)
| Scenario | Behavior |
|---|---|
| Cache hit (key in cache) | Allow (cryptographically verified) |
| Cache miss (key unknown) | 401 Invalid Token (deny) |
| Cache expired | 401 Invalid Token (deny) |
Database Unavailable
| Scenario | Behavior |
|---|---|
| Postgres unreachable | 503 Service Unavailable (deny) |
Audit of Auth Decisions
Every request logs its auth decision:
SELECT actor, operation, strategy, outcome, fail_mode, timestampFROM platform.audit_logWHERE actor = 'ravi.kumar'ORDER BY timestamp DESC LIMIT 10;Output:
actor operation outcome fail_mode timestamp─────────────────────────────────────────────────────────────────────ravi.kumar CREATE success NONE 2026-05-19 14:32ravi.kumar READ denied_rbac NONE 2026-05-19 14:31ravi.kumar UPDATE denied_cel_abac NONE 2026-05-19 14:30ravi.kumar READ success REDIS_CACHED_ALLOWED 14:29Revoking Access
Immediate Revocation (Actor)
Delete the RoleBinding:
velocity revoke --actor ravi.kumar \ --schema acme/supply-chain/procurement/purchase-order/v1The operator writes the actor to Redis’s revocation set. Subsequent requests are denied within seconds.
Token Expiration
Set short TTLs on tokens. A revoked actor’s existing token remains valid until expiration. Shorter TTLs = faster revocation.
API Key Revocation
velocity api-key revoke --name prod-secretImmediate; no cache to clear.
Best Practices
- Use short-lived tokens: 15-60 minutes. Forces re-authentication and makes revocation faster.
- Scope JWT to audience:
audience: acme-apiprevents token reuse across services. - IP-restrict API keys: If possible, use
ipAllowlistto limit where keys can be used. - Rotate API keys monthly: Use
velocity api-key createwith a new name, revoke the old one. - Use OIDC for browsers: Cookies + session state is more secure than bearer tokens in browser storage.
- Use JWT for services: Simpler, stateless, no session overhead.
- Test fail modes: Kill Redis and verify requests are denied (not allowed).
- Monitor auth failures: Alert on spike in 401 or 403 errors.
Examples
Create an AuthStrategy for JWT
kubectl apply -f - <<EOFapiVersion: velocity.sh/v1kind: AuthStrategymetadata: name: jwt-acme namespace: platformspec: type: jwt displayName: "Acme Internal JWT" jwt: issuer: https://auth.acme.com audience: acme-api jwksUrl: https://auth.acme.com/.well-known/jwks.json claimMapping: actorId: sub roles: - path: realm_access.roles transform: identity attributes: department: path: custom_claims.department transform: identity revocation: failOpen: falseEOFTest with curl
# Get a token (from your IdP)TOKEN=$(curl -X POST https://auth.acme.com/token \ -d "username=ravi.kumar&password=secret" | jq -r .access_token)
# Use itcurl -H "Authorization: Bearer $TOKEN" \ https://api.velocity.acme.com/api/acme/supply-chain/procurement/purchase-order/v1Grant roles to a user
velocity grant \ --actor ravi.kumar \ --roles procurement-reader \ --schema acme/supply-chain/procurement/purchase-order/v1 \ --scope region=west \ --expires 2026-12-31Create an API key for CI/CD
velocity api-key create \ --name deploy-service \ --ttl 90d
# Output: vel_deploy-service_xxxyyy...# Use in deploy script: curl -H "X-API-Key: vel_deploy-service_xxxyyy..." ...