Skip to main content

Authorization

SpiceDB Integration for Fine-Grained Permissions

This document covers the technical implementation of Amply's authorization system using SpiceDB. For design rationale and conceptual overview, see Architecture: Authorization.

Overview

SpiceDB runs as a sidecar container alongside the backend on ECS. The backend communicates with SpiceDB over localhost gRPC using the authzed-py async client. Every permission-sensitive operation checks SpiceDB before proceeding.

┌──────────────────────────────────────────────────────┐
│ ECS Task │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Backend │◄─gRPC──►│ SpiceDB │ │
│ │ (FastAPI) │ localhost│ (sidecar) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
└─────────┼────────────────────────┼────────────────────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ amply_dev │ │ spicedb_dev│
│ (RDS) │ │ (RDS) │
└────────────┘ └────────────┘
Application DB SpiceDB's DB
(same RDS instance, separate databases)

SpiceDB Schema

The schema defines resource types, their relationships, and how permissions are computed. Source of truth: amply-backend/spicedb/schema.zed.

definition user {}

definition platform {
relation platform_admin: user
relation reviewer: user
relation support_agent: user
relation finance_staff: user
relation auditor: user

permission admin = platform_admin
permission review_organizations = reviewer + platform_admin
permission support_access = support_agent + platform_admin
permission view_finances = finance_staff + platform_admin + auditor
permission audit = auditor + platform_admin
}

definition organization {
relation parent: platform
relation owner: user
relation admin: user
relation finance: user
relation content: user
relation viewer: user

// Role inheritance: owner > admin > finance/content > viewer
permission manage = owner
permission administer = owner + admin
permission view_finances = administer + finance + parent->view_finances
permission edit_content = administer + content
permission view = administer + finance + content + viewer + parent->support_access + parent->audit

// Specific permissions
permission manage_members = administer
permission manage_funds = administer + finance
permission manage_campaigns = administer + content
permission view_donations = view_finances + parent->audit
permission view_ledger = view
permission update_settings = administer
permission delete = owner

// Staff access through platform parent
permission staff_review = parent->review_organizations
permission staff_support = parent->support_access
permission staff_audit = parent->audit
}

definition fund {
relation parent: organization

permission view = parent->view
permission manage = parent->manage_funds
permission view_balance = parent->view_finances
permission create_transaction = parent->manage_funds
}

definition campaign {
relation parent: organization
relation owner: user
relation manager: user

permission view = parent->view + owner + manager
permission update = parent->edit_content + owner + manager
permission manage = parent->administer + owner
permission delete = parent->administer + owner
permission view_donors = parent->view_finances + owner
}

definition user_profile {
relation self: user

permission view = self
permission update = self
}

definition api_key {
relation owner: organization
relation scope_read: organization
relation scope_write: organization

permission read = owner->view + scope_read->view
permission write = owner->administer + scope_write->administer
}

Schema Conventions

  • parent relations express hierarchy (fund -> organization -> platform)
  • Permissions use + for union (either relationship grants the permission)
  • Arrow notation parent->permission means "check this permission on the parent"
  • Role inheritance is modeled via permission definitions, not role hierarchies
  • definition user {} is required by SpiceDB even though user is a built-in concept

Schema Evolution

Schema changes must be backward-compatible:

  • Adding a new relation or permission: safe, deploy anytime
  • Removing a relation: remove all tuples using that relation first, then remove from schema
  • Renaming: treat as add-new + migrate-data + remove-old

Schema changes are reviewed in PRs like any other code change.

SpiceDB Client

Location: app/core/authorization.py

The client uses the authzed-py async gRPC client (AsyncClient), wrapped in a SpiceDBClient class that provides typed helper methods.

Client Architecture

# core/authorization.py
from authzed.api.v1 import AsyncClient

class SpiceDBClient:
"""Wrapper around authzed-py AsyncClient with typed helpers."""

def __init__(self, client: AsyncClient) -> None:
self._client = client

async def check_permission(self, *, resource_type, resource_id, permission, subject_type, subject_id) -> bool:
"""Check if subject has permission on resource."""

async def write_relationship(self, *, resource_type, resource_id, relation, subject_type, subject_id) -> None:
"""Write a relationship tuple (OPERATION_TOUCH — idempotent upsert)."""

async def delete_relationship(self, *, resource_type, resource_id, relation, subject_type, subject_id) -> None:
"""Delete a specific relationship tuple."""

async def delete_all_relationships(self, *, resource_type, resource_id) -> None:
"""Delete ALL relationships for a resource (used on resource deletion)."""

Singleton Management

_client: SpiceDBClient | None = None

def get_spicedb_client() -> SpiceDBClient:
"""Get SpiceDB client singleton. Creates on first call."""
global _client
if _client is None:
settings = get_settings()
if settings.spicedb_tls_enabled:
credentials = bearer_token_credentials(settings.spicedb_token)
else:
credentials = insecure_bearer_token_credentials(settings.spicedb_token)
raw_client = AsyncClient(settings.spicedb_target, credentials)
_client = SpiceDBClient(raw_client)
return _client

def reset_spicedb_client() -> None:
"""Reset the singleton (for testing)."""
global _client
_client = None

Convenience Functions

Module-level functions delegate to the singleton, but accept an optional client parameter for testing:

async def check_permission(*, client=None, resource_type, resource_id, permission, subject_type, subject_id) -> bool:
async def write_relationship(*, client=None, resource_type, resource_id, relation, subject_type, subject_id) -> None:
async def delete_relationship(*, client=None, resource_type, resource_id, relation, subject_type, subject_id) -> None:
async def delete_all_relationships(*, client=None, resource_type, resource_id) -> None:

FastAPI Integration

Permission Check Function

Location: app/api/deps.py

require_permission is an async function called imperatively by endpoints (not wired as a router dependency via Depends). Its current_user parameter carries a Depends(get_current_user) annotation, but in practice endpoints pass their already-resolved CurrentUser explicitly.

# api/deps.py
async def require_permission(
resource_type: str,
resource_id: str,
permission: str,
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Check SpiceDB permission. Raises 403 if denied."""
allowed = await check_permission(
resource_type=resource_type,
resource_id=resource_id,
permission=permission,
subject_type="user",
subject_id=str(current_user.id),
)
if not allowed:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user

Usage Pattern in Endpoints

Permission checks are called after the resource is resolved from the DB, before any mutation:

# api/v1/organizations.py — organization-level check
@router.get("/mine")
async def get_my_organization(current_user: CurrentUser, db: DBSession):
org = await get_user_organization(db, current_user.id)
if not org:
raise HTTPException(status_code=404, detail="No organization found")

await require_permission("organization", str(org.id), "view", current_user)
return organization_to_response(org)
# api/v1/organizations.py — resource-level check (fund)
@router.patch("/mine/funds/{fund_id}")
async def update_fund(fund_id: str, request: FundUpdate, current_user: CurrentUser, db: DBSession):
org = await get_user_organization(db, current_user.id)
# ... resolve fund from DB ...

# Check fund-level permission, not org-level
await require_permission("fund", str(fund.id), "manage", current_user)

# ... apply updates ...
# api/v1/campaigns.py — resource-level check (campaign)
@router.get("/mine/{campaign_id}")
async def get_campaign(campaign_id: str, current_user: CurrentUser, db: DBSession):
# ... resolve campaign from DB ...

await require_permission("campaign", str(campaign.id), "view", current_user)
return campaign_to_response(campaign)

Permission Check Matrix

Every protected endpoint and the SpiceDB permission it checks:

EndpointResource TypePermission
GET /organizations/mineorganizationview
PATCH /organizations/mineorganizationupdate_settings
GET /organizations/mine/summaryorganizationview
GET /organizations/mine/donationsorganizationview_donations
GET /organizations/mine/ledgerorganizationview_ledger
GET /organizations/mine/fundsorganizationview
POST /organizations/mine/fundsorganizationmanage_funds
PATCH /organizations/mine/funds/{id}fundmanage
GET /campaigns/mineorganizationview
POST /campaigns/mineorganizationmanage_campaigns
GET /campaigns/mine/{id}campaignview
PATCH /campaigns/mine/{id}campaignupdate

Resource-level checks (fund, campaign) resolve permissions through the SpiceDB parent relationship. For example, fund:X#manage resolves to organization:Y#manage_funds via the parent relation defined in the schema.

Relationship Sync

Location: app/services/authorization.py

Every time the application creates, updates, or deletes an entity that has SpiceDB relationships, the corresponding sync function must be called.

Retry Strategy

All write operations use exponential backoff (3 attempts: 0.1s, 0.5s, 2.0s delays):

_MAX_RETRIES = 3
_RETRY_DELAYS = [0.1, 0.5, 2.0]

async def _retry_write(coro_factory, *, description: str) -> None:
"""Retry an async SpiceDB write with exponential backoff."""
for attempt in range(_MAX_RETRIES):
try:
await coro_factory()
return
except Exception:
if attempt == _MAX_RETRIES - 1:
logger.error("SpiceDB write failed after %d attempts: %s", _MAX_RETRIES, description)
raise
delay = _RETRY_DELAYS[attempt]
logger.warning("SpiceDB write attempt %d/%d failed for %s, retrying in %.1fs",
attempt + 1, _MAX_RETRIES, description, delay)
await asyncio.sleep(delay)

Sync Functions

FunctionWhen CalledRelationship Written
sync_organization_created(org_id)After org DB commitorganization:X#parent@platform:amply
sync_member_added(org_id, user_id, role)After member DB commitorganization:X#<role>@user:Y
sync_member_removed(org_id, user_id, role)After member removalDeletes organization:X#<role>@user:Y
sync_member_role_changed(org_id, user_id, old, new)After role updateDeletes old role, writes new role
sync_fund_created(fund_id, org_id)After fund DB commitfund:X#parent@organization:Y
sync_campaign_created(campaign_id, org_id, creator_id)After campaign DB commitcampaign:X#parent@organization:Y + campaign:X#owner@user:Z
sync_fund_deleted(fund_id)Before/after fund deletionDeletes ALL relationships for fund:X
sync_campaign_deleted(campaign_id)Before/after campaign deletionDeletes ALL relationships for campaign:X
sync_organization_deleted(org_id)Before/after org deletionDeletes ALL relationships for organization:X

Consistency Strategy

Application database and SpiceDB are separate systems. Sync functions are called after the database commit:

  1. Database is always correct (source of truth for business data)
  2. SpiceDB may lag briefly on write failure (until retry succeeds)
  3. Fail-safe: if SpiceDB lacks a relationship, access is denied (never incorrectly granted)

This is acceptable because a brief denial of access (until retry writes the relationship) is far safer than a brief grant of unauthorized access.

Delete Sync

Resource deletion uses delete_all_relationships which removes every relationship where the resource appears as the primary object. This prevents orphaned tuples in SpiceDB that could cause stale permission grants if IDs are ever reused.

async def sync_fund_deleted(fund_id: str) -> None:
"""Delete all relationships for a fund."""
await delete_all_relationships(resource_type="fund", resource_id=fund_id)

Note: Resource deletion syncs (sync_fund_deleted, sync_campaign_deleted, sync_organization_deleted) do not use retry, because a failed delete is fail-safe — the resource no longer exists in the DB, so permission checks fail at the DB lookup stage before reaching SpiceDB. Member removal syncs (sync_member_removed, sync_member_role_changed) do use retry, because the organization still exists and a missing deletion could leave stale access.

Configuration

Environment Variables

VariableDefaultDescription
SPICEDB_TARGETlocalhost:50051gRPC endpoint
SPICEDB_TOKEN(required)Pre-shared key for authentication
SPICEDB_TLS_ENABLEDfalseUse TLS (required for non-localhost)

ECS Task Definition

SpiceDB runs as a sidecar container within the backend's ECS task:

{
"containerDefinitions": [
{
"name": "backend",
"image": "amply-backend:latest",
"portMappings": [{"containerPort": 8000}],
"environment": [
{"name": "SPICEDB_TARGET", "value": "localhost:50051"}
]
},
{
"name": "spicedb",
"image": "authzed/spicedb:latest",
"command": ["serve", "--grpc-preshared-key", "FROM_SSM", "--datastore-engine", "postgres",
"--datastore-conn-uri", "FROM_SSM", "--http-enabled"],
"portMappings": [
{"containerPort": 50051},
{"containerPort": 8443}
],
"cpu": 256,
"memory": 256
}
]
}

SpiceDB flags of note:

  • --http-enabled enables the HTTP API on port 8443 (used for health checks and debugging; SpiceDB's distroless image has no shell or curl)
  • --grpc-preshared-key authenticates gRPC clients
  • --datastore-engine postgres with --datastore-conn-uri for the dedicated SpiceDB database

Schema Management

The SpiceDB schema file lives at amply-backend/spicedb/schema.zed. It is applied via a dedicated ECS migration task (amply-spicedb-migrate task family) that runs spicedb migrate head followed by spicedb schema write.

Testing

Autouse Mock Fixture

All tests mock SpiceDB calls via an autouse fixture in tests/conftest.py. Permission checks default to True (allowed) so existing tests pass without SpiceDB. Tests that specifically verify authorization behavior override the mock.

# tests/conftest.py
@pytest.fixture(autouse=True)
def mock_spicedb(monkeypatch):
"""Mock SpiceDB calls in all tests."""
mock_check = AsyncMock(return_value=True)
mock_write = AsyncMock()
mock_delete = AsyncMock()
mock_delete_all = AsyncMock()

monkeypatch.setattr("app.core.authorization.check_permission", mock_check)
monkeypatch.setattr("app.core.authorization.write_relationship", mock_write)
monkeypatch.setattr("app.core.authorization.delete_relationship", mock_delete)
monkeypatch.setattr("app.core.authorization.delete_all_relationships", mock_delete_all)

return {
"check_permission": mock_check,
"write_relationship": mock_write,
"delete_relationship": mock_delete,
"delete_all_relationships": mock_delete_all,
}

Testing Permission Denials

Tests that verify 403 behavior patch check_permission to return False:

# tests/test_api/test_organizations_mine_permission.py
class TestOrganizationsMinePermission:
async def test_get_mine_returns_403_when_denied(self, authenticated_client, ...):
# Setup: user is org member in DB, but SpiceDB denies
with patch("app.api.deps.check_permission", AsyncMock(return_value=False)):
response = await authenticated_client.get("/v1/organizations/mine")
assert response.status_code == 403

Testing Resource-Level Checks

Tests verify that the correct resource type and permission are checked:

# tests/test_api/test_funds_resource_permission.py
class TestFundResourcePermissions:
async def test_update_fund_checks_fund_manage(self, authenticated_client, ...):
mock_check = AsyncMock(return_value=True)
with patch("app.api.deps.check_permission", mock_check):
response = await authenticated_client.patch(
f"/v1/organizations/mine/funds/{ext_id}",
json={"name": "Updated Fund Name"},
)
assert response.status_code == 200
call_kwargs = mock_check.call_args.kwargs
assert call_kwargs["resource_type"] == "fund" # Not "organization"
assert call_kwargs["permission"] == "manage"
# tests/test_api/test_campaigns_resource_permission.py
class TestCampaignResourcePermissions:
async def test_get_campaign_checks_campaign_view(self, authenticated_client, ...):
mock_check = AsyncMock(return_value=True)
with patch("app.api.deps.check_permission", mock_check):
response = await authenticated_client.get(f"/v1/campaigns/mine/{ext_id}")
call_kwargs = mock_check.call_args.kwargs
assert call_kwargs["resource_type"] == "campaign" # Not "organization"
assert call_kwargs["permission"] == "view"

Monitoring

Health Check

SpiceDB exposes an HTTP health endpoint (port 8443, requires --http-enabled). Note: the health endpoint returns HTTP 200 even when SpiceDB cannot reach its database — ECS health checks pass regardless of actual readiness. Monitor SpiceDB's ability to serve permission checks via the backend's own health endpoint.

Metrics

SpiceDB exposes Prometheus metrics on port 9090:

  • spicedb_dispatch_count — number of permission checks
  • spicedb_dispatch_duration_seconds — check latency
  • spicedb_datastore_query_count — database queries

Alerting

MetricAlert ThresholdSeverity
SpiceDB container unhealthy> 30 secondsCritical
Permission check latency p95> 50msWarning
Permission check error rate> 1%Critical
Relationship write failures> 0Warning

Related: