Zum Hauptinhalt springen

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.

Implementation details

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:

ResourcePermissions
Own profileView, update
Own donationsView history, download receipts
Own campaigns (fundraisers)Create, update, view stats
Own payment methodsAdd, remove, set default
Own recurring donationsView, 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:

RoleWhat They Can Do
OwnerEverything, including delete organization and transfer ownership
AdminAll settings, user management, fund management, full financial access
FinanceTransactions, payouts, financial reports, fund balances
ContentProfile editing, campaigns, communications, public-facing content
ViewerRead-only dashboard access

Full role capabilities

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 RoleScope
Platform AdminFull system access, user management, configuration
ReviewerReview organization applications, approve/reject
Support AgentView user accounts and organization data for support purposes
Finance StaffPlatform-level financial reports, Stripe dashboard access
AuditorRead-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:amply chain
  • No need to assign permissions per-resource — the hierarchy handles it

Reverse Queries

SpiceDB supports queries that traditional permission systems cannot answer efficiently:

QuerySpiceDB APIUse Case
"What can user X access?"LookupResourcesDashboard: show appropriate menu items
"Who has access to org Y?"LookupSubjectsAudit: list all users with access
"What permissions does X have on Y?"LookupPermissionsUI: 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:

  1. User must have a relationship to an organization to access any of its data
  2. Resources belong to organizations via parent relationships
  3. No relationship = no access — there is no "default allow"
  4. 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

Implementation details: Retry Strategy

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:

FieldExample
Timestamp2026-02-16T14:30:00Z
Actoruser:bob
Actionfund.update
Resourcefund:general
Organizationorganization:acme
Resultallowed
IP Address203.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:

DevProduction
SpiceDB containerECS sidecar (0.25 vCPU, 256MB)ECS sidecar (0.5+ vCPU, 512MB+)
DatabaseSeparate DB on dev RDS instanceSeparate DB on prod RDS instance
SchemaSame (deployed from repo)Same (deployed from repo)
Relationship dataDev/test dataReal 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: