Launch Rail Logo
Launch Rail
Documentation

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 decision
  • BatchCheckPermissions — many checks in one call
  • ListAllowedObjects — objects a subject can access
  • ListSubjects — 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 sync
  • WatchPolicy — 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.

HeaderRequiredNotes
X-Authz-CallerYesLogical caller name. Must match a configured caller=secret pair in SECURITY_TRUSTED_CALLERS.
X-Authz-TimestampYesRFC3339 timestamp. Requests outside SECURITY_MAX_CLOCK_SKEW are rejected.
X-Authz-SignatureYesBase64-encoded HMAC-SHA256 of the canonical payload.
X-Company-IDYesAuthenticated tenant ID — the boundary used by the service.
X-User-IDUsuallyEnd-user identity for user-scoped requests.
X-Request-IDRecommendedUsed 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))
}

Shared Caller SDK

Use the importable client at github.com/launch-rail/authz/pkg/authzclient to call Authz from any Go service without duplicating signing logic.

client, err := authzclient.New(authzclient.Config{
    BaseURL: "http://authz.internal",
    Caller:  "gateway",
    Secret:  os.Getenv("AUTHZ_SHARED_SECRET"),
})

ctx = authzclient.WithIdentity(ctx, authzclient.Identity{
    TenantID:  tenantID,
    UserID:    userID,
    RequestID: requestID,
    UserAgent: "appointments-service/1.12.0",
})

resp, err := client.Runtime.CheckPermission(ctx,
    connect.NewRequest(&authzv1.CheckPermissionRequest{
        Subject: &authzv1.Subject{Ref: &authzv1.Subject_UserId{UserId: userID}},
        Action:  &authzv1.Action{Name: "appointment.read"},
        Object:  &authzv1.ObjectRef{Type: "appointment", Id: appointmentID},
    }),
)

Tenant Enforcement Rules

The tenant boundary comes from authenticated caller headers, not from the request body.

Enforcement Rules

  • X-Company-ID is the source of truth for tenant isolation.
  • RequestContext.tenant_id is optional but must match X-Company-ID when 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.

launchrail-ctl — zsh
$
CLI SimulationActive

Safe Write Patterns

Idempotent Creates

Use stable keys to make repeated runs safe:

  • Role.key and RoleBinding.key
  • idempotency_key on create endpoints
  • SyncPolicy.sync_id for stable sync identifiers

Optimistic Concurrency

Delete and patch APIs support version checks via expected_version:

  1. Read the current entity and note its version.
  2. Send expected_version on update or delete.
  3. 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_token

Persist 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_id
  • ip_address, user_agent
  • user_id, user_email, user_role
  • session_id, caller_id
  • attributes — custom key-value map
Other variables
  • subject — the requesting subject
  • object — the target object
  • action — 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

  1. Create internal/service/authorization/providers/<provider-name>.
  2. Add a provider struct wrapping the target SDK or API.
  3. Implement all runtime interface methods.
  4. Implement Kind() for correct evaluated_by and trace metadata.
  5. Register the provider in server startup and configuration.
  6. 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