Security model
AfriAnnotate is a multi-tenant platform. The security model below describes the auth, transport, storage, and isolation guarantees the software gives you — independent of which cloud or self-hosted environment it's deployed to.
The worked examples below describe the AfriAnnotate cloud at
afriannotate.waraka.org, which runs on Google Cloud Run + Cloud SQL + GCS
with DNS at the registrar (Dynadot; the legacy label.afriannotate.org
name is on Cloudflare and redirects here). Self-hosters get the same application-level
guarantees (HttpOnly cookies, query-level org filtering,
PBKDF2 password hashing) regardless of the underlying infrastructure —
the differences are in who operates the TLS terminator, HSTS,
database backups, and storage encryption.
Transport​
- All traffic to
afriannotate.waraka.orgis HTTPS only. The TLS certificate is managed by the hoster's TLS terminator (Cloud Run, ALB, Caddy, nginx + Let's Encrypt — operator's choice). The reference cloud uses Google-managed certs that auto-renew. - HTTP requests are 301-redirected to HTTPS at the edge.
- The app itself does not set an HSTS header — enable HSTS at your TLS terminator / edge if you want browsers pinned to HTTPS (the reference cloud does this at the load balancer).
- The reference cloud uses plain DNS records (no proxying CDN in front), so the TLS session is end-to-end between the browser and the origin load balancer with a Google-managed certificate. Self-hosters can put any CDN / no CDN in front; wherever TLS terminates is also where HSTS belongs.
Authentication​
- Web + mobile: email + password. Passwords are stored as Django PBKDF2 hashes (870 000 iterations, the Django 5.1 default), never plaintext.
- Email verification is required before sign-in for all non- first users. Verify links are one-shot tokens with a 24-hour TTL; clicking marks the user verified and stamps the audit log.
- Session cookies are
HttpOnlyandSameSite=Lax, scoped toafriannotate.waraka.org. TheSecureflag is operator-controlled via theSESSION_COOKIE_SECUREenv var (off by default — set it on any HTTPS deployment). Lifetime is governed by the per-org session policy. - JWT tokens are minted on every session login and stored in
localStoragefor the SPA. Token lifetimes default to 24 hours (access) / 30 days (refresh, tunable viaAFRI_REFRESH_TOKEN_DAYS). Refresh tokens are not rotated on use — one refresh token lives for its full window. - API tokens (PATs) are JWTs you generate yourself in Profile → Settings. They use the same Bearer-header authentication as session-minted JWTs. See API → Authentication.
Rate limits + brute-force protection​
- Anonymous auth endpoints are rate-limited with exponential
backoff (wait doubles per attempt within a 24-hour window):
- Forgot password, resend verification, email change: keyed per (email, IP), base 60s, cap 30 min.
- Account-status probe (the cheap email-first check): keyed per email only (so users behind one carrier-grade-NAT IP don't throttle each other), 8 free attempts, then base 15s, cap 10 min.
- Failed-login attempts trigger no per-account lockout — industry experience shows lockouts cause more support load from legitimate users than they prevent unauthorised access. The login endpoint itself is not app-rate-limited; put edge rate limiting in front if credential-stuffing is a concern.
- Operator-tunable:
PlatformSettingslets the platform owner adjust the rate-limit base + cap for each endpoint without a deploy.
Authorisation​
AfriAnnotate's role model is two-tiered:
- Org membership + role: every user belongs to zero or more organisations. Within each org they hold one role: Owner (OW), Admin (AD), Manager (MA), Reviewer (RE), Annotator (AN), or Guest (GU). The role gates what they can see and do in that org. See Roles for the full matrix.
- Platform-level flags:
is_staff(platform administration: Platform Settings, users, the list of organisations, quotas, freeze) andis_superuser(the same, plus the Django admin). The first user on a fresh platform is auto-promoted to both; subsequent users default to neither. Neither flag is a role in any organisation.
Within a project, members can hold a per-project role that overrides their org-level role (e.g. an org-level Admin who's only an Annotator on a specific project). Project roles are managed from Project → Members.
Staff access to other organisations' data​
Running the platform does not let you into an organisation. Its projects, data (tasks, annotations, exports), members, policies, payouts and settings are refused to platform staff and superusers alike, and they cannot switch into it, unless the organisation lets support in: one of its owners opens the door for a set time, with a reason, from Organization → Settings → Access → Platform support. See Letting platform support in.
While an organisation has let support in:
- Superusers can switch into it and work in it.
- Staff also need the two things below, as before:
- A per-account grant: Full data access on a staff user, set only by a superuser from Platform → Users. Give it only to the few people who genuinely need it.
- A platform policy: Platform → Settings → Policies → "Staff
access to other orgs' project data", one of
Off(granted staff still cannot),Audited(default) (they can, and every view is written to the audit log asstaff.cross_org_data_accesswith the actor, org, project and surface), orOn.
When the time runs out or the organisation ends it, anyone from the platform who was switched into it is taken out on their next request.
What stays with the platform whatever an organisation decides: creating, freezing, deleting and transferring organisations, quotas, platform users, and platform settings. An organisation left with no active owner has nobody to let support in, so it is the platform's to look after until it has an owner again.
Platform staff who are also members of an organisation have, in it, the role they were given there and nothing more.
The Django admin (/admin/, superusers) follows the same rule: it
lists users, organisations, memberships, migrations and platform
settings, and nothing of an organisation's content (projects, tasks,
annotations, predictions, model backends, model runs, provider
connections).
Recommendation. Keep the policy on Audited, grant Full data
access per person, and for testing add the tester as a member of a
dedicated test organisation.
Data storage​
- Database: PostgreSQL. The reference cloud runs Google Cloud SQL with daily automated backups + 7-day point-in-time recovery, encrypted at rest by GCP. Self-hosters configure backups + encryption against their own Postgres.
- Uploaded files (CSVs, images, audio): object storage. The reference cloud uses Google Cloud Storage; self-hosters can point the platform at any S3 / GCS / Azure Blob bucket via env vars. Buckets are private; access is short-lived signed URLs minted by the backend.
- User passwords: Django PBKDF2 hashes (one-way; never reversible).
- Cloud connector credentials (GCS / S3 / Azure access keys
you provide for external storage integration): encrypted with
SECRET_KEYbefore being stored in the database. - Third-party API tokens (Hugging Face Hub PAT and any future
Weights & Biases / Comet / SageMaker-style secrets a user pastes
in to enable a per-user integration): encrypted at rest via
core.encrypted_fields.EncryptedCharField. Fernet cipher keyed from a dedicatedHF_TOKEN_ENCRYPTION_KEYenv var (recommended for production so key rotation is decoupled fromSECRET_KEY), with aSECRET_KEY-derived fallback so fresh dev environments work without extra config. Never returned in any API response — the User serializer surfaces only a booleanhas_hf_tokenflag; the raw value only leaves the DB when the server-side push/import code makes an outbound call to the third-party API. - JWT signing key: the same Django
SECRET_KEY, rotated only on operator-initiated key rotation (which invalidates all live tokens).
DB-only compromise threat model​
The encrypted-at-rest layer protects against DB-only compromise
(leaked backup file, read-only SQL access via a misconfigured
replica, a table-dump from an over-permissive support agent). An
attacker with just DB access can't turn the ciphertext into a usable
Hugging Face token — they'd need HF_TOKEN_ENCRYPTION_KEY (or the
current SECRET_KEY) too.
It does NOT protect against RCE on the app server or memory scraping of a running app process — an attacker in the app's own process can load the key and decrypt. That tier needs a real secrets manager (GCP Secret Manager / HashiCorp Vault / AWS KMS + envelope encryption) with credentials that don't sit on the app node, and it's on the operator to wire up when the threat model demands it.
Multi-tenancy​
- Every database row carries an organisation foreign key.
- Every query is filtered by the requesting user's active organisation; the org filter is enforced at the queryset level in DRF permissions, not just at the URL level. There's no path by which a user in org A can see rows from org B by guessing IDs.
- The active organisation switcher in the sidebar changes the
active_organizationforeign key on the user row; from that point, all subsequent queries scope to the new org. A person switches only into organisations they belong to; the people who run the platform, additionally, into one that has let support in (see Staff access to other organisations' data above), and they are taken out again when that ends. - Isolation is enforced at two layers: the queryset filter
(list endpoints) and the object-level access gate that every
id-keyed endpoint runs (
user_can_access_project— org membership, a project-member/contributor row, the creator, or an authorised staff grant). A privileged write additionally requires the right role. There is no path by which a member of org A can read or change org B's projects, tasks, annotations, exports, or uploads by guessing IDs.
Audit log​
Operator-visible actions land in an audit log:
- User events: signup, login, logout, password change, email change, manual-verify by admin, deactivation, role changes, org additions/removals.
- Org events: create, rename, member added/removed.
- Project events: create, archive, delete, publish.
- Staff data access:
staff.cross_org_data_access— logged (inAuditedmode) whenever a granted staff user opens the project / data-manager / export of an organisation they are not a member of. - Token events: PAT created / revoked; third-party token set /
cleared (Hugging Face Hub PAT — the audit row includes actor,
IP, timestamp, and a 3-character prefix like
hf_as a flavour marker; the raw token value is never written to the log). - HF Hub events: dataset import, push (fresh / enrich-source / fork), unbind. Push events capture the target repo id + aggregation strategy.
Each entry records: action type, target object (user / project /
org), the staff member who took the action (or the user
themselves for self-service actions), timestamp, and a small JSON
meta blob with action-specific detail.
Access via Platform → Audit log. Staff-only. Searchable + exportable to CSV.
Integrity​
- Append-only, enforced in the database. On PostgreSQL a row-level
trigger (
core_auditevent_append_only, migrationcore.0028) refuses everyDELETEand everyUPDATEoncore_auditevent. The only change it permits is theON DELETE SET NULLcascade onactor_id/organization_idwhen a user or organisation is deleted; no other column may change, and re-pointing an event at a different user is refused. The snapshottedactor_emailandtarget_labelkeep the row meaningful after the cascade. Tests:core/tests/test_audit_append_only.py. - What it protects against: application bugs, ORM
update()/delete()calls, and ad-hoc SQL run as the application role. - What it does not protect against: a database superuser or the table owner dropping the trigger, or restoring an older backup. For that tier run the app under a role that does not own the table, keep backups immutable, and export the log periodically.
- SQLite installs no trigger. The desktop app's local mirror and dev instances run on SQLite, where the sync engine legitimately re-writes mirrored audit rows. The store that matters for tamper evidence is the cloud's PostgreSQL.
- Retention: indefinite; there is no retention setting and nothing in the product prunes the table.
- Consent signatures carry their own tamper evidence: an HMAC-SHA256 hash chain with a verify-chain endpoint — see Consent library.
Compliance​
- Privacy notice: the reference cloud publishes https://afriannotate.waraka.org/docs/privacy-notice, linked from the sign-up consent line and the app footer (Platform → Settings → External links → Privacy notice). Self-hosters edit the page for their own deployment before linking it.
- GDPR / POPIA (South Africa): users can self-delete their account from the profile page (cascades soft-deletes across their annotations + cloud storage refs). Org admins can do the same on behalf of removed members.
- Consent management: the platform supports per-purpose consent templates managed at the org level (annotator agreement, data retention policy, etc.). See Consent library when published.
- Data retention: deletions hard-purge after 30 days. Audit entries for deletions are kept indefinitely so the deletion itself is traceable.
Reporting a vulnerability​
Email security disclosures to afriannotate@gmail.com (the policy is
also in the repo's SECURITY.md). We aim to acknowledge within 48 hours and ship a fix within the standard
GitHub Security Advisory disclosure window (typically 14-90 days
depending on severity).
If the vulnerability is in upstream dependencies (Django, DRF, your cloud provider's runtime / storage), report upstream first; we'll mirror the fix when the upstream patch lands. For AfriAnnotate-specific issues, report directly to the email above — please don't open a public GitHub issue for an unpatched vulnerability.