Authorization
Who Can Do What, and Why
Amply uses a relationship-based authorization system powered by SpiceDB, an open-source implementation of Google's Zanzibar authorization model. This document explains the design rationale, permission domains, and how authorization fits into Amply's architecture.
Why Relationship-Based Access Control
Traditional role-based access control (RBAC) answers: "Does this user have the admin role?" That works for simple systems but breaks down when you need to ask more nuanced questions:
- Can user X edit this specific fund in this specific organization?
- Who has access to organization Y's financial data? (reverse lookup)
- Can this API key read donations for organization Z but not organization W?
- If user X is a member of Team A, and Team A manages Fund B, can X view Fund B's transactions?
These are relationship questions, not role questions. Amply's domain is naturally relational: users belong to organizations, organizations have funds, campaigns belong to organizations, staff members are assigned to review specific organizations.
Why Not Policy-Based (Cerbos, OPA)
Policy engines like Cerbos evaluate conditions: "Allow if user.role == admin AND resource.org_id == user.org_id AND resource.status == active." This creates a dual-logic problem — business state conditions (is the campaign active? is Stripe connected?) end up defined in both the policy engine's YAML files and in the application's Python code. Over time, these drift apart, creating hard-to-debug authorization failures.
SpiceDB avoids this entirely. It only answers relationship questions: "Does this user have a relationship to this resource that grants this permission?" All business logic (campaign status, Stripe readiness, etc.) stays in Python — one place, one source of truth.
Why Not Build In-House
Authorization is security-critical. A bug in a hand-rolled permission system on a financial platform means potential unauthorized access to money and data. SpiceDB provides:
- Mathematically consistent permission evaluation
- Battle-tested at scale (powers OpenAI's ChatGPT Enterprise, among others)
- Reverse queries ("who has access to X?") that would require significant custom engineering
- Apache 2.0 open-source — no licensing cost, no vendor lock-in
Design Decision
Decision: SpiceDB (self-hosted, open-source) as the authorization engine.
Date: 2026-02-16
Rationale:
- Amply handles real money — authorization correctness is non-negotiable
- Multi-tenant from day one — relationship model naturally expresses tenant isolation
- Clean separation: SpiceDB owns "who can access what," Python owns "is this action valid"
- Reverse queries essential for a transparency platform (audit: "who has access to this org's data?")
- Zero licensing cost (Apache 2.0), runs as a sidecar container on existing ECS infrastructure
Trade-offs accepted:
- Additional container to operate (minimal — stateless except for its dedicated PostgreSQL database)
- Relationship tuples must be written on every data mutation (mechanical, one extra call per write)
- SpiceDB schema changes must be backward-compatible (managed via schema-as-code in the repository)
Permission Domains
Amply has four distinct access contexts, each with different authorization needs:
Public Access
No authentication required. Anyone can view:
- Organization profiles and public stats
- Public ledger entries (respecting visibility levels)
- Donation pages, campaign pages
- SDG browsing, effectiveness ratings
No SpiceDB involvement — these are open endpoints.
Contributor Access
Authenticated donors, businesses, and fundraisers accessing their own data:
| Resource | Permissions |
|---|---|
| Own profile | View, update |
| Own donations | View history, download receipts |
| Own campaigns (fundraisers) | Create, update, view stats |
| Own payment methods | Add, remove, set default |
| Own recurring donations | View, pause, cancel |
SpiceDB relationships: user:X viewer user:X (self-relationship), plus campaign ownership.
Organization Member Access
Users with roles within an organization. This is the most complex domain:
| Role | What They Can Do |
|---|---|
| Owner | Everything, including delete organization and transfer ownership |
| Admin | All settings, user management, fund management, full financial access |
| Finance | Transactions, payouts, financial reports, fund balances |
| Content | Profile editing, campaigns, communications, public-facing content |
| Viewer | Read-only dashboard access |
SpiceDB models these as relationships between users and organizations:
user:alice owner organization:acme
user:bob admin organization:acme
user:carol finance organization:acme
Permissions are computed from these relationships. An owner inherits all admin permissions, an admin inherits all finance and content permissions, etc.
Amply Staff Access
Internal Amply team members with platform-level responsibilities:
| Staff Role | Scope |
|---|---|
| Platform Admin | Full system access, user management, configuration |
| Reviewer | Review organization applications, approve/reject |
| Support Agent | View user accounts and organization data for support purposes |
| Finance Staff | Platform-level financial reports, Stripe dashboard access |
| Auditor | Read-only access to all data for compliance and audit purposes |
Staff roles are global (not scoped to a single organization). A reviewer can review any organization. A support agent can view any user's account.
SpiceDB models these as relationships to the platform itself:
user:dave platform_admin platform:amply
user:eve reviewer platform:amply
user:frank support_agent platform:amply
Critical principle: Staff permissions are explicit, not implicit. There is no is_admin = True flag on users. Staff access is granted via specific SpiceDB relationships and can be revoked instantly.
Relationship Model
Core Relationships
platform:amply
├── platform_admin → user:... (full system access)
├── reviewer → user:... (org review access)
├── support_agent → user:... (support access)
├── finance_staff → user:... (platform financial access)
└── auditor → user:... (read-only audit access)
organization:acme
├── owner → user:alice
├── admin → user:bob
├── finance → user:carol
├── content → user:dave
├── viewer → user:eve
└── parent → platform:amply (staff can access via platform relationship)
fund:general
└── parent → organization:acme (inherits org permissions)
campaign:save-the-reef
├── owner → user:frank (fundraiser who created it)
├── manager → user:grace (co-manager)
└── parent → organization:acme (org permissions apply)
api_key:k1
└── viewer → organization:acme (scoped API access)
Permission Inheritance
Permissions flow through relationships:
Can user:bob view fund:general?
1. Is bob directly related to fund:general? → No
2. fund:general has parent organization:acme
3. Is bob related to organization:acme? → Yes, as admin
4. Does admin include fund view permission? → Yes
5. Result: ALLOWED
This means:
- Organization members automatically get appropriate access to all funds, campaigns, and resources within that organization
- Staff with platform-level roles can access any organization through the
parent → platform:amplychain - No need to assign permissions per-resource — the hierarchy handles it
Reverse Queries
SpiceDB supports queries that traditional permission systems cannot answer efficiently:
| Query | SpiceDB API | Use Case |
|---|---|---|
| "What can user X access?" | LookupResources | Dashboard: show appropriate menu items |
| "Who has access to org Y?" | LookupSubjects | Audit: list all users with access |
| "What permissions does X have on Y?" | LookupPermissions | UI: show available actions |
For a transparency platform, these reverse queries are essential for compliance, auditing, and building a trustworthy permission management UI.
Multi-Tenant Isolation
How Tenants Are Isolated
Every organization is a tenant. SpiceDB enforces isolation through the relationship model:
- User must have a relationship to an organization to access any of its data
- Resources belong to organizations via
parentrelationships - No relationship = no access — there is no "default allow"
- Cross-tenant access is impossible unless explicitly granted (e.g., staff roles)
This is enforced independently of application code. Even if a bug in the Python backend skips a permission check, SpiceDB's relationship model prevents cross-tenant data access at the authorization layer.
Database-Level Reinforcement
SpiceDB authorization is the primary gate. Additionally, all PostgreSQL queries are scoped to organization_id, providing a second layer of isolation:
Request → SpiceDB check (permission gate)
→ PostgreSQL query (always scoped to organization_id)
Two independent layers ensure tenant isolation. Row-level security (RLS) policies may be added as a third layer in the future for defense-in-depth.
Separation of Concerns
What SpiceDB Handles (Authorization)
- Is this user a member of this organization?
- What role does this user have?
- Can this user perform this action on this resource?
- Who has access to this resource? (reverse lookup)
- Can this API key access this organization?
What Python Handles (Business Logic)
- Is this campaign active?
- Is the organization's Stripe account connected?
- Is the donation amount within acceptable limits?
- Has the organization passed review?
- Is the email verified?
The Clean Boundary
FastAPI endpoint receives request
│
▼
SpiceDB: "Is this user allowed to touch this resource?"
│ (pure authorization — relationships only)
│
▼ (if allowed)
Python service: "Is this action valid given current business state?"
│ (business rules — campaign status, Stripe, limits)
│
▼ (if valid)
Database: Execute the operation
No duplication. No logic drift. One system owns authorization, another owns business rules.
Consistency Between Systems
The application database and SpiceDB are separate datastores. Relationship writes happen after the database commit with automatic retry (exponential backoff, 3 attempts). The design is fail-safe:
- If a relationship write fails, the resource exists in the DB but SpiceDB denies access (no relationship = no permission)
- This means a temporary write failure causes a brief access denial, never an unauthorized access grant
- Resource deletions clean up all SpiceDB relationships for that resource to prevent orphaned tuples
Audit Trail
Current State
Permission check results and relationship writes are logged via Python's standard logging:
- Permission denials are logged at WARNING level with user, resource, and permission
- Relationship write failures are logged at ERROR level with full retry context
- Successful writes are logged at INFO level
Planned: Structured Audit Records
A structured audit system will capture every permission-checked mutation:
| Field | Example |
|---|---|
| Timestamp | 2026-02-16T14:30:00Z |
| Actor | user:bob |
| Action | fund.update |
| Resource | fund:general |
| Organization | organization:acme |
| Result | allowed |
| IP Address | 203.0.113.42 |
| Changes | {name: "General" → "General Fund"} |
Staff actions will include elevated detail (role used, organization accessed, data viewed/modified, duration).
This aligns with Amply's transparency principle — the platform's own access controls should be auditable.
Environment Strategy
Dev and Production
Each environment runs its own SpiceDB instance with its own dedicated database:
| Dev | Production | |
|---|---|---|
| SpiceDB container | ECS sidecar (0.25 vCPU, 256MB) | ECS sidecar (0.5+ vCPU, 512MB+) |
| Database | Separate DB on dev RDS instance | Separate DB on prod RDS instance |
| Schema | Same (deployed from repo) | Same (deployed from repo) |
| Relationship data | Dev/test data | Real production relationships |
Key constraint: SpiceDB requires a dedicated PostgreSQL database. It cannot share a database with the application. However, it can use a separate database on the same RDS instance — no additional RDS cost.
Dev RDS Instance
├── amply_dev ← application database
└── spicedb_dev ← SpiceDB's dedicated database
Prod RDS Instance
├── amply_prod ← application database
└── spicedb_prod ← SpiceDB's dedicated database
The SpiceDB schema is versioned in the repository and deployed identically to both environments. Only the relationship data differs — dev has test relationships, production has real ones created by the application at runtime.
Cost
SpiceDB open-source is Apache 2.0 — zero licensing cost.
Infrastructure cost for the dev environment: ~$7-10/month additional (0.25 vCPU Fargate container, separate database on existing RDS instance).
Production cost scales with usage but starts similarly low. SpiceDB is CPU-efficient — a small container handles thousands of permission checks per second.
Related: