Authz Service
A centralized authorization engine for multi-tenant platforms. Answers permission queries via ConnectRPC and gRPC using roles, bindings, direct grants, object edges, CEL conditions, and a streaming policy sync layer.
Overview
The Authz service is a centralized authorization engine that answers a single question: "Can subject S perform action A on object O?" Every service in your platform calls Authz instead of implementing its own permission logic. The DB provider evaluates direct grants, role bindings, role action expansion, object-edge inheritance, time-based access windows, and CEL conditions.
Runtime API
CheckPermission, Batch, ListAllowedObjects, ListSubjects — for request-time decisions.
Policy API
Roles, bindings, direct grants, object edges, SyncPolicy, WatchPolicy — for policy lifecycle.
Deny by Default
No matching grant or binding means DECISION_DENY. Access must be explicitly granted.
Choose the Right API
AuthorizationService (Runtime)
CheckPermission— single request decisionBatchCheckPermissions— many checks in one callListAllowedObjects— objects a subject can accessListSubjects— who has access to an object
AuthorizationPolicyService (Management)
- Roles — create, update, delete, list
- Role Bindings — subject + scope + optional condition
- Direct Grants — action-level one-off grants
- Object Edges — parent-child hierarchy graph
SyncPolicy— streaming bulk syncWatchPolicy— revision-driven change stream
Caller Authentication
Authz does not trust raw identity headers from arbitrary callers. Every request must carry a gateway-signed caller envelope validated using HMAC-SHA256.
| Header | Required | Notes |
|---|---|---|
X-Authz-Caller | Yes | Logical caller name. Must match a configured caller=secret pair in SECURITY_TRUSTED_CALLERS. |
X-Authz-Timestamp | Yes | RFC3339 timestamp. Requests outside SECURITY_MAX_CLOCK_SKEW are rejected. |
X-Authz-Signature | Yes | Base64-encoded HMAC-SHA256 of the canonical payload. |
X-Company-ID | Yes | Authenticated tenant ID — the boundary used by the service. |
X-User-ID | Usually | End-user identity for user-scoped requests. |
X-Request-ID | Recommended | Used in the signature payload, auditing, and troubleshooting. |
Signature payload (newline-separated):
<caller>
<procedure>
<http-method>
<request-id>
<user-id>
<company-id>
<timestamp>func authzSignature(secret, caller, procedure, method, requestID, userID, companyID, timestamp string) string {
payload := strings.Join([]string{
caller, procedure, method,
requestID, userID, companyID, timestamp,
}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}Tenant Enforcement Rules
The tenant boundary comes from authenticated caller headers, not from the request body.
Enforcement Rules
- •
X-Company-IDis the source of truth for tenant isolation. - •
RequestContext.tenant_idis optional but must matchX-Company-IDwhen present. - • Policy payload fields must not attempt to change tenant ownership.
- •
WatchPolicy.tenant_id, when provided, must match the authenticated tenant. - • Tenant mismatches return
PermissionDenied.
Runtime API
CheckPermission
Use CheckPermission for single request-time decisions. The response includes the decision, reason code, and the provider that evaluated it.
// Request
{
"subject": { "user_id": "0f5d462f-1d68-4c70-9de9-35c2f06e7b64" },
"action": { "name": "schedule.read" },
"object": { "type": "resource:ROOM", "id": "6d729fda-f1a4-4f5d-a6d4-60f9e3d9d9c0" },
"context": {
"tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86",
"request_id": "req-schedule-42",
"ip_address": "10.0.0.24",
"user_agent": "scheduling-service/1.12.0"
}
}
// Response
{
"decision": "DECISION_ALLOW",
"reason": "allowed",
"evaluated_by": "PROVIDER_KIND_DB",
"reason_code": "DECISION_REASON_CODE_ALLOWED",
"consistency_token": "184",
"policy_revision": "184"
}Batch & List Endpoints
BatchCheckPermissions
One subject needs many checks in the same request. Avoids N round-trips.
ListAllowedObjects
For list pages — returns all object IDs the subject has a specific permission for.
ListSubjects
For share dialogs, audit views, and reverse lookups — who has access?
// ListAllowedObjects
{
"subject": { "user_id": "0f5d462f-1d68-4c70-9de9-35c2f06e7b64" },
"action": { "name": "calendar.view" },
"object_types": [{ "type": "resource:CALENDAR" }],
"context": { "tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86" },
"page_size": 50
}Read-Your-Write Consistency
All mutating policy RPCs return the latest tenant revision in the X-Authz-Consistency-Token response header. Pass it into subsequent runtime calls to guarantee the evaluation sees the latest policy.
// After a write, use the token in the next check
{
"subject": { "user_id": "0f5d462f-..." },
"action": { "name": "team.view" },
"object": { "type": "team", "id": "c2d02b37-..." },
"context": { "tenant_id": "2ef6b2e0-..." },
"consistency_token": "184"
}If Authz has not yet observed the requested revision, the runtime responds with DECISION_REASON_CODE_POLICY_NOT_READY. Retry after a short backoff.
Policy Modeling Guidance
The DB provider evaluates: direct grants, role bindings, role action expansion, object-edge inheritance, starts_at/expires_at, and CEL conditions.
Roles
Use for stable, reusable capability sets. Keep action names dot-separated (e.g. files.read, schedule.write).
Role Bindings
Use for broad access at a scope: company, team, or another parent object.
Direct Grants
Use for exceptions and temporary overrides. Supports expires_at and CEL conditions.
Object Edges
Use to express inheritance: team → company, appointment → clinic. Permissions propagate through the graph.
Safe Write Patterns
Idempotent Creates
Use stable keys to make repeated runs safe:
- •
Role.keyandRoleBinding.key - •
idempotency_keyon create endpoints - •
SyncPolicy.sync_idfor stable sync identifiers
Optimistic Concurrency
Delete and patch APIs support version checks via expected_version:
- Read the current entity and note its
version. - Send
expected_versionon update or delete. - Retry only after re-reading if the server returns an optimistic locking failure.
Patch Example: UpdateRoleBinding
{
"context": { "tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86" },
"binding_id": "5265fcbb-4c7d-42ed-a127-b5d8e5e9d383",
"expected_version": 3,
"patch": { "expires_at": "2026-04-01T00:00:00Z" },
"update_mask": { "paths": ["expires_at"] }
}SyncPolicy
SyncPolicy is the main integration point for source-of-truth sync jobs. It is client-streaming — send chunks across the stream, and they are committed transactionally.
replace=true means "make this tenant match the streamed snapshot exactly." Tenant-scoped roles, bindings, grants, and edges not in the stream are deleted.
// Chunk 1 — roles
{
"context": { "tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86" },
"sync_id": "identity-sync-2026-03-18T10:00:00Z",
"replace": true,
"roles": [{
"key": "company_admin",
"name": "Company Admin",
"actions": ["schedule.read", "schedule.write"]
}]
}
// Chunk 2 — bindings
{
"context": { "tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86" },
"sync_id": "identity-sync-2026-03-18T10:00:00Z",
"role_bindings": [{
"key": "company-admin:user:0f5d462f-...",
"role_id": "4c33752c-...",
"subject": { "user_id": "0f5d462f-..." },
"scope": { "type": "company", "id": "2ef6b2e0-..." }
}]
}
// Response
{
"provider": "PROVIDER_KIND_DB",
"synced_at": "2026-03-18T10:00:02Z",
"consistency_token": "221",
"roles_upserted": "1",
"role_bindings_upserted": "1"
}WatchPolicy
WatchPolicy is tenant-scoped and revision-driven. Use it to keep policy caches, sidecar indexers, and downstream materialized views up to date.
// Resume from the last seen revision
{
"tenant_id": "2ef6b2e0-d680-4777-9fc3-8d2d9b781f86",
"resume_token": "221"
}
// Each streamed event includes:
// new_consistency_token — persist and reuse as the next resume_tokenPersist the last delivered new_consistency_token and reuse it as the resume_token on reconnect. The server replays backlog after that revision before streaming live events.
CEL Conditions
Conditions are evaluated using Common Expression Language (CEL). Attach them to role bindings or direct grants for fine-grained, context-aware access control without extra database round-trips.
Available CEL Variables
request.*
tenant_id,request_idip_address,user_agentuser_id,user_email,user_rolesession_id,caller_idattributes— custom key-value map
Other variables
subject— the requesting subjectobject— the target objectaction— the requested action
// Example: support agents with an approved ticket
request.user_role == "support" && request.attributes.ticket_state == "approved"
// Example: IP allowlist for sensitive operations
request.ip_address.startsWith("10.0.")
// Example: time-boxed access (use starts_at/expires_at instead when possible)
request.attributes.shift_active == "true"Guidance: Prefer roles and edges first, conditions second. Keep expressions small and deterministic. Avoid expressions that depend on unstable formatting or large payloads.
Custom Providers
The runtime layer can be extended with a custom authorization backend by implementing AccessProvider — defined in internal/service/authorization/service.go.
type AccessProvider interface {
CheckPermission(ctx context.Context, namespace, object, relation, subjectID string) (bool, error)
AddRelation(ctx context.Context, namespace, object, relation, subjectID string) error
AddSubjectSetRelation(ctx context.Context, namespace, object, relation, subjectNamespace, subjectObject, subjectRelation string) error
RemoveRelation(ctx context.Context, namespace, object, relation, subjectID string) error
ListRelations(ctx context.Context, namespace, object, relation, subjectID, pageToken string) ([]RelationTuple, string, error)
}
type ProviderDescriptor interface {
Kind() authzv1.ProviderKind
}Implementation Steps
- Create
internal/service/authorization/providers/<provider-name>. - Add a provider struct wrapping the target SDK or API.
- Implement all runtime interface methods.
- Implement
Kind()for correctevaluated_byand trace metadata. - Register the provider in server startup and configuration.
- Add integration tests for runtime checks, pagination, and failure behavior.
// Minimal skeleton
type Provider struct{}
func (p *Provider) Kind() authzv1.ProviderKind {
return authzv1.ProviderKind_PROVIDER_KIND_UNSPECIFIED
}
func (p *Provider) CheckPermission(ctx context.Context, namespace, object, relation, subjectID string) (bool, error) {
return false, nil // deny by default
}
func (p *Provider) ListRelations(ctx context.Context, namespace, object, relation, subjectID, pageToken string) ([]authorization.RelationTuple, string, error) {
return nil, "", nil
}Validation & Error Handling
InvalidArgument
Missing top-level request, missing nested messages (grant.action, edge.parent), invalid UUIDs, missing patch payload.
PermissionDenied
Tenant mismatch between X-Company-ID and request body fields.
POLICY_NOT_READY
The requested consistency_token revision has not been observed yet. Retry after backoff.
Pagination
Treat next_page_token as opaque. Changing filters invalidates an existing token. Never parse or persist token structure.
Interactive API Reference
Explore all Authz endpoints — runtime checks, policy management, sync, and audit — in the interactive Swagger UI.
View Authz API Reference