Skip to content

docs product ADMIN_TIERS_CONTRACT

AlexandreZanata edited this page Nov 5, 2026 · 1 revision

Admin suite, granted tiers, contributors and staff roles — contract v1

Status: PROPOSED with ADR-023. Plan: ADMIN_CONTRIBUTOR_PLAN. Rule IDs: B-BR-ADM-* (admin auth), B-BR-ENT-* (granted tiers), B-BR-CTB-* (contributors), B-BR-STF-* (staff roles), use cases BUC-ADM-*. Every rule below needs at least one test that cites its ID in the test name or comment. A rule without a passing test is not implemented.

1. Vocabulary

Term Meaning
Admin The project owner holding the single administrative key. Not an app account.
Admin key abv_admin_ + 43 base64url chars (32 random bytes). Lives only in the owner's password manager.
Admin session Opaque server session obtained by presenting the key once.
Tier Named bundle of capabilities from the frozen catalog (§3).
Grant Admin-created, time-bounded or open-ended assignment of one tier to one account.
Repository contributor GitHub login listed by the GitHub contributors API for ANPFUEL_GITHUB_REPO.
Contributor Account with an ACTIVE, unexpired CONTRIBUTOR grant.
Staff role Code-defined responsibility (§5) assigned by the admin to an account.

2. Admin authentication (B-BR-ADM)

  • ADM-01 Configuration: ANPFUEL_ADMIN_KEY_SHA256 = lowercase hex SHA-256 of the full key string (64 chars). Optional ANPFUEL_ADMIN_KEY_SHA256_NEXT for rotation. A malformed value fails API startup. When neither is set, no /v1/admin/* route is registered (404 for every admin path).
  • ADM-02 The raw key never exists on the server: not in env, files, DB, logs, metrics, errors or audit. ops admin keygen prints the key and its hash once to stdout and writes nothing to disk.
  • ADM-03 Login is POST /v1/admin/session with JSON {"key": "..."} only. Keys in query string, path, cookies or headers are ignored and the request is 400. Comparison: SHA-256 of the presented key against each configured hash with crypto/subtle.ConstantTimeCompare. Any failure is 401 admin.denied with an identical body and no hint (wrong format, wrong key, disabled key).
  • ADM-04 Brute-force limits: per client IP (resolved with the existing trusted-proxy logic) at most 5 failed logins per 15 minutes → 429 with Retry-After; globally at most 50 failed logins per hour → all logins 429 for 15 minutes. Successful login does not reset the global counter. Limits are stored in PostgreSQL (survive restart) and are concurrency-safe.
  • ADM-05 Session: 32 random bytes, base64url, returned only as cookie __Host-abv_admin (HttpOnly; Secure; SameSite=Strict; Path=/). Stored as a salted hash in admin_sessions with key_fingerprint (first 12 hex of the configured hash that matched), created_at, last_seen_at, expires_at, revoked_at. Idle timeout 30 min, absolute lifetime 8 h. Login always issues a new session (no fixation).
  • ADM-06 CSRF: login also returns {"csrf_token": "..."} (random, stored hashed per session). Every non-GET admin request must carry header X-Admin-CSRF matching it and an Origin header exactly equal to ANPFUEL_ADMIN_ORIGIN (e.g. https://api.example.org). Missing/mismatch → 403 admin.csrf. GET admin endpoints have no side effects.
  • ADM-07 Rotation: a session is valid only while its key_fingerprint matches a currently configured hash. Removing a hash invalidates its sessions on the next request. POST /v1/admin/sessions/revoke-all revokes every session.
  • ADM-08 Optional network fence ANPFUEL_ADMIN_ALLOWED_CIDRS: when set, admin requests from other client IPs get 404.
  • ADM-09 Every admin response: Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer. Admin request bodies are limited to 16 KiB and decoded with unknown fields rejected.
  • ADM-10 Audit: table admin_audit_events is append-only (a trigger raises on UPDATE/DELETE). Rows: id, at, session_id (nullable for failed login), action, target_type, target_id, reason, before (jsonb), after (jsonb), request_id, client_ip_hash. Failed logins are audited without any key material. Every successful mutation writes its audit row in the same transaction.
  • ADM-11 An account bearer/session token never authenticates an admin request, and an admin session never authenticates a user request.
  • ADM-12 Every mutation needs reason (10–500 Unicode chars after trim) and header Idempotency-Key (UUID). Same key + same body replays the stored result; same key + different body → 409.

3. Tier catalog and granted tiers (B-BR-ENT)

Catalog tier-catalog-v1 (Go constant in entitlement/domain, golden JSON in contracts/admin/tier-catalog-v1.json):

Tier Capabilities Notes
PRO cloud.sync, cloud.backup, cloud.restore, alerts.advanced, professional.write, professional.report, vehicles.multiple Same bundle as paid Premium.
CONTRIBUTOR PRO bundle + contributor.profile Public credit (§4).
ENTERPRISE PRO bundle Label only in v1. No org/team/seat/station/cross-account right.

FREE is the absence of any capability; it is never stored.

  • ENT-01 Only an admin session can create or revoke a grant. No user, role holder, payment or webhook path writes entitlement_grants.
  • ENT-02 Target account must exist with status active; suspended or deleted → 409 entitlement.account-ineligible.
  • ENT-03 At most one ACTIVE grant per (account, tier) — partial unique index. A second grant → 409 entitlement.exists unless the request has "replace": true, which revokes the old grant and inserts the new one in one transaction (two audit rows).
  • ENT-04 end_at is optional. When present: now + 1h ≤ end_at ≤ now + 5 years. null means until revoked. start_at is always the server time.
  • ENT-05 Capabilities are derived at evaluation time from (tier, policy_version) via the catalog. The table stores no capability array, and request bodies containing capabilities are rejected (unknown field).
  • ENT-06 A grant counts when state='ACTIVE' AND start_at <= now AND (end_at IS NULL OR end_at > now). Expiry needs no job.
  • ENT-07 Revoke sets state='REVOKED', revoked_at, revoke_reason. Revoked grants are never reactivated; a new grant is a new row. A capability check that starts after the revoke commits must deny.
  • ENT-08 Capability projection = union of Play, web payment and admin grant capabilities. Admin grants never change payment rows, and payments/refunds never change admin grants.
  • ENT-09 /v1/premium/status adds tiers: array of {"tier","source","valid_until"} where source ∈ {play, web, admin}; paid Premium reports tier PRO. Existing fields keep their meaning; when only an admin grant is active, state="ACTIVE" and reason="verified-admin".
  • ENT-10 Admin grants must work when payment ports are not configured.
  • ENT-11 Account erasure revokes all active grants with reason account-erased inside the erasure flow.
  • ENT-12 Grants never alter trust, moderation, confidence tier, ranking, feedback weight or price-result placement (OPEN_SOURCE_BUSINESS).

4. Contributors (B-BR-CTB)

  • CTB-01 "Validated repository contributor" = login present in the server-side snapshot of GET https://api.github.com/repos/{ANPFUEL_GITHUB_REPO}/contributors (all pages, per_page=100). Snapshot refresh: at most once per minute on admin request, cached for 1 hour, 5 s timeout, 1 MiB response cap, optional read-only ANPFUEL_GITHUB_TOKEN. No new Go dependency (net/http).
  • CTB-02 Two ways to make a contributor: (a) admin grants CONTRIBUTOR directly to any active account (arbitrary, audited); (b) the user opens a claim with a GitHub login and the admin approves it.
  • CTB-03 Claim: signed-in user submits github_login (GitHub rules: 1–39 chars, [A-Za-z0-9-], no leading/trailing/double -). Server returns a one-time code abastevo-contributor- + 16 base32 chars. Max 1 open claim per account, max 3 claims per account per 24 h. Claims expire after 30 days.
  • CTB-04 Optional ownership proof: the user creates a public gist on that GitHub account containing the code and submits the gist ID. The server fetches GET https://api.github.com/gists/{id} and marks the proof VERIFIED only if owner.login equals the claimed login (case-insensitive) and any file content contains the exact code. Otherwise UNVERIFIED.
  • CTB-05 Admin approval of a claim creates, in one transaction, the CONTRIBUTOR grant (no end date by default) and the GitHub link. Approving an UNVERIFIED claim or a login absent from the snapshot requires "override": true plus reason; the audit row records the override.
  • CTB-06 One GitHub login ↔ at most one account, and one account ↔ at most one login, among active links (unique partial indexes). Conflicts → 409.
  • CTB-07 Claim states: PENDING → APPROVED | REJECTED | WITHDRAWN | EXPIRED. Terminal states are final.
  • CTB-08 Public profile, editable only while the account holds contributor.profile:
    • display_name: NFC-normalized, trimmed, 2–40 runes; letters, marks, digits, space, ., ', - only; no @, /, : or control characters.

    • links: 0–2 items of {platform, handle}; one link per platform. Allowlist and handle rules:

      Platform Handle rule Server-built URL
      github GitHub login rule https://github.com/{h}
      gitlab [A-Za-z0-9_.-]{2,255} https://gitlab.com/{h}
      linkedin [A-Za-z0-9-]{3,100} https://www.linkedin.com/in/{h}
      x [A-Za-z0-9_]{1,15} https://x.com/{h}
      instagram [A-Za-z0-9_.]{1,30} https://www.instagram.com/{h}
      youtube [A-Za-z0-9_.-]{3,30} https://www.youtube.com/@{h}
      bluesky DNS-like [a-z0-9.-]{3,253} with a dot https://bsky.app/profile/{h}

      Clients send only platform + handle; any URL or scheme in a handle is rejected. Clients must render only the server-built url and re-check that its host is in the allowlist before opening it.

    • visibility: PUBLIC | HIDDEN; default HIDDEN until the user saves with public_consent: true.

  • CTB-09 A profile appears in GET /v1/contributors only when: active CONTRIBUTOR grant + visibility=PUBLIC + not admin-hidden + account active. Revoking the grant removes it on the next read (cache ≤ 5 min).
  • CTB-10 Public item fields only: display_name, links[] {platform, handle, url}, github_login (only when linked; shown as "verified GitHub contributor"), contributor_since (month YYYY-MM), roles[] (public role labels, §5). Never account_id, alias, email, grant ids or reasons. Order: contributor_since ascending, then an opaque stable id; cursor pagination, max 50 per page. Cache-Control: public, max-age=300.
  • CTB-11 CORS: only GET /v1/contributors, only for origins listed in ANPFUEL_PUBLIC_WEB_ORIGINS; no credentials; no other route gains CORS.
  • CTB-12 Profile edits: max 10 per account per 24 h. Admin can hide (reason) and unhide; hidden profiles keep the data but are not listed.
  • CTB-13 Account erasure deletes the profile, links and claim codes and revokes the GitHub link; the public list stops showing it immediately.
  • CTB-14 Contributor status never alters trust, moderation, ranking or feedback weight (same as ENT-12).

5. Staff roles (B-BR-STF)

Catalog staff-roles-v2 (Go constant in staff/domain, golden JSON contracts/admin/staff-roles-v2.json; v2 adds project_creator, migration 000145, owner request 2026-10-28; staff-roles-v1.json stays as history):

Role Public label (pt-BR) Intended responsibility (future consumer)
project_creator Criador do projeto Public badge for the project's author; no responsibility or permission
community_moderator Moderação da comunidade Review reports and apply moderation actions
station_reviewer Revisão de postos Review station suggestions and profile claims
contributor_curator Curadoria de contribuidores Pre-review contributor claims
support_agent Suporte Read-only account status lookup for support
  • STF-01 Roles exist only in code. No endpoint creates, renames or edits a role or its permissions.
  • STF-02 Only an admin session assigns/revokes. Target account must be active. One ACTIVE assignment per (account, role); optional end_at with the same bounds as ENT-04; same reason/idempotency/audit rules.
  • STF-03 In v1 a role grants no permission. Each future consumer endpoint is its own task with its own authorization and negative tests.
  • STF-04 Roles never confer admin powers and never alter trust, moderation outcome or ranking by themselves.
  • STF-05 The owner sees their own active roles in an authenticated read; public role labels appear in the contributors list only when the account is a public contributor and show_roles_publicly is true (default false).
  • STF-06 Account erasure revokes all active assignments.

6. Use cases (BUC-ADM)

ID Actor Flow Key failures
BUC-ADM-01 Admin Logs in with key, gets session, logs out wrong key 401, rate limit 429, disabled 404
BUC-ADM-02 Admin Finds an account by UUID, exact alias, alias prefix (≥3 chars) or exact email (hashed server-side) short query 400, no match empty list
BUC-ADM-03 Admin Grants PRO/ENTERPRISE to a test user for N days ineligible account 409, duplicate 409, bad end_at 400
BUC-ADM-04 Admin Revokes a grant already revoked 409
BUC-ADM-05 Admin Views GitHub contributors with link/claim state GitHub down → cached snapshot + stale=true
BUC-ADM-06 User Opens contributor claim, adds gist proof invalid login 400, open claim 409, quota 429
BUC-ADM-07 Admin Approves/rejects claim, or links directly unverified without override 409, link conflict 409
BUC-ADM-08 Contributor Edits display name + up to 2 links, publishes no capability 403, invalid link 400, quota 429
BUC-ADM-09 Anyone Lists contributors in app / landing none (empty list is valid)
BUC-ADM-10 Admin Assigns/revokes staff role unknown role 400, duplicate 409
BUC-ADM-11 Admin Hides/unhides a contributor profile not found 404
BUC-ADM-12 Admin Reads audit log with filters —

7. HTTP surface

Admin (cookie session + CSRF on mutations, all no-store):

Method & path Purpose
POST /v1/admin/session Login {key} → cookie + {csrf_token, expires_at}
GET /v1/admin/session Who-am-I: {key_fingerprint, expires_at}
DELETE /v1/admin/session Logout
POST /v1/admin/sessions/revoke-all Revoke all admin sessions
GET /v1/admin/accounts?q= Search (max 20)
GET /v1/admin/accounts/{id} Detail: alias, status, created_at, grants, paid summary (state only), roles, contributor link/profile/claims
POST /v1/admin/accounts/{id}/grants {tier, end_at?, reason, replace?}
GET /v1/admin/grants?tier=&state=&cursor= List grants
POST /v1/admin/grants/{id}/revoke {reason}
GET /v1/admin/github/contributors?refresh= Snapshot + link/claim state
GET /v1/admin/contributor-claims?state=&cursor= Claims queue
POST /v1/admin/contributor-claims/{id}/approve {reason, override?}
POST /v1/admin/contributor-claims/{id}/reject {reason}
POST /v1/admin/accounts/{id}/github-link Direct link {login, reason, override?}
POST /v1/admin/contributor-profiles/{account_id}/hide · /unhide {reason}
GET /v1/admin/roles Role catalog
POST /v1/admin/accounts/{id}/roles {role, end_at?, reason}
POST /v1/admin/role-assignments/{id}/revoke {reason}
GET /v1/admin/audit?action=&target_id=&cursor= Audit read

User (same proof pattern as /v1/premium/status: family_id + access_token body and signed owner):

Method & path Purpose
POST /v1/premium/status Additive tiers (ENT-09)
POST /v1/contributor/claims Open claim {github_login, operation_id} → {claim_id, code}
POST /v1/contributor/claims/read Own claims
POST /v1/contributor/claims/{id}/proof {gist_id}
POST /v1/contributor/claims/{id}/withdraw —
POST /v1/contributor/profile/read Own profile + roles
POST /v1/contributor/profile Upsert {display_name, links, visibility, public_consent, show_roles_publicly}

Public: GET /v1/contributors?cursor= (CTB-10/11).

All routes must be added to contracts/openapi/v1.yaml in the same PR that implements them (the apicontract test enforces it). Error codes follow the existing httpapi.WriteError style: admin.*, entitlement.*, contributor.*, staff.*.

Fuel prices wiki

Source: AlexandreZanata/brazil-fuel-prices @ 99f218303462287ad0d2122e065fa9d9759314ee

Clone this wiki locally