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
parentrelations express hierarchy (fund -> organization -> platform)- Permissions use
+for union (either relationship grants the permission) - Arrow notation
parent->permissionmeans "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:
| Endpoint | Resource Type | Permission |
|---|---|---|
GET /organizations/mine | organization | view |
PATCH /organizations/mine | organization | update_settings |
GET /organizations/mine/summary | organization | view |
GET /organizations/mine/donations | organization | view_donations |
GET /organizations/mine/ledger | organization | view_ledger |
GET /organizations/mine/funds | organization | view |
POST /organizations/mine/funds | organization | manage_funds |
PATCH /organizations/mine/funds/{id} | fund | manage |
GET /campaigns/mine | organization | view |
POST /campaigns/mine | organization | manage_campaigns |
GET /campaigns/mine/{id} | campaign | view |
PATCH /campaigns/mine/{id} | campaign | update |
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
| Function | When Called | Relationship Written |
|---|---|---|
sync_organization_created(org_id) | After org DB commit | organization:X#parent@platform:amply |
sync_member_added(org_id, user_id, role) | After member DB commit | organization:X#<role>@user:Y |
sync_member_removed(org_id, user_id, role) | After member removal | Deletes organization:X#<role>@user:Y |
sync_member_role_changed(org_id, user_id, old, new) | After role update | Deletes old role, writes new role |
sync_fund_created(fund_id, org_id) | After fund DB commit | fund:X#parent@organization:Y |
sync_campaign_created(campaign_id, org_id, creator_id) | After campaign DB commit | campaign:X#parent@organization:Y + campaign:X#owner@user:Z |
sync_fund_deleted(fund_id) | Before/after fund deletion | Deletes ALL relationships for fund:X |
sync_campaign_deleted(campaign_id) | Before/after campaign deletion | Deletes ALL relationships for campaign:X |
sync_organization_deleted(org_id) | Before/after org deletion | Deletes ALL relationships for organization:X |
Consistency Strategy
Application database and SpiceDB are separate systems. Sync functions are called after the database commit:
- Database is always correct (source of truth for business data)
- SpiceDB may lag briefly on write failure (until retry succeeds)
- 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
| Variable | Default | Description |
|---|---|---|
SPICEDB_TARGET | localhost:50051 | gRPC endpoint |
SPICEDB_TOKEN | (required) | Pre-shared key for authentication |
SPICEDB_TLS_ENABLED | false | Use 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-enabledenables the HTTP API on port 8443 (used for health checks and debugging; SpiceDB's distroless image has no shell or curl)--grpc-preshared-keyauthenticates gRPC clients--datastore-engine postgreswith--datastore-conn-urifor 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 checksspicedb_dispatch_duration_seconds— check latencyspicedb_datastore_query_count— database queries
Alerting
| Metric | Alert Threshold | Severity |
|---|---|---|
| SpiceDB container unhealthy | > 30 seconds | Critical |
| Permission check latency p95 | > 50ms | Warning |
| Permission check error rate | > 1% | Critical |
| Relationship write failures | > 0 | Warning |
Related: