M13_API_CONTRACT.md 14 KB

M13 — API Contract (v1)

Wire-level contract for the M13 frontend. Two services: admind (existing, gets new endpoints) and authd (new). All endpoints JSON in/out unless noted. Auth = Authorization: Bearer <jwt> for access, Cookie: refresh=... for refresh.


0. Conventions

  • Base paths: /v1/auth/* (authd), /v1/* (admind).
  • SSE: text/event-stream.
  • All timestamps RFC 3339 UTC.
  • IDs: UUID v4 unless otherwise noted.
  • Errors: { "error": { "code": "string", "message": "string", "request_id": "uuid" } }.
  • Pagination: ?limit=N&cursor=<opaque>. Response: { "items": [...], "next_cursor": "..." | null }.
  • Tenant scope: enforced server-side from JWT company_id. Super-admin may pass ?company_id=<id> to scope; tenant-admin ?company_id matches the JWT or returns 403.

1. authd endpoints (port 8804)

1.1 POST /v1/auth/login

Request:

{ "username": "lrosales", "password": "..." }

Response 200:

{
  "access_token": "eyJ...",
  "access_expires_in": 900,
  "user": { "id": "uuid", "username": "lrosales", "role": "super_admin", "company_id": null }
}

Sets cookie: refresh=<opaque>; HttpOnly; Secure; SameSite=Lax; Path=/v1/auth; Max-Age=604800.

Errors: 401 invalid credentials, 423 account locked (5 failed attempts in 5min).

1.2 POST /v1/auth/refresh

Request: empty body. Cookie must carry refresh. Response 200: same shape as login. New refresh cookie sent, old one invalidated. Errors: 401 invalid/expired refresh.

1.3 POST /v1/auth/logout

Request: empty body. Requires access JWT. Response 204. Invalidates the refresh token server-side. Clears cookie. Errors: 401.

1.4 GET /v1/auth/me

Response 200:

{
  "id": "uuid",
  "username": "lrosales",
  "role": "super_admin",
  "company_id": null,
  "display_name": "Luis Rosales",
  "email": "lrosales@techno-world.net",
  "created_at": "2026-06-16T18:00:00Z",
  "last_login_at": "2026-06-16T20:30:00Z"
}

1.5 POST /v1/auth/invites (super-admin only)

Request:

{
  "username": "gerente@acme.com",
  "role": "tenant_admin",
  "company_id": "acme-001",
  "display_name": "Gerente ACME",
  "email": "gerente@acme.com"
}

Response 201:

{
  "invite": { "id": "uuid", "token": "raw-token-shown-once", "expires_at": "..." },
  "magic_link": "https://admind.netpolice.online/invite/accept?token=...",
  "email_sent": true
}

email_sent=false when SMTP env vars are unset — frontend shows the magic_link directly so super-admin can paste it. This is the "B with fallback to A" decision.

Errors: 403 (not super-admin), 409 (username exists), 404 (company_id not found).

1.6 POST /v1/auth/invites/accept

Request:

{ "token": "raw-token-from-invite", "password": "new-password-here" }

Response 200: same as login shape. User is now status=active. Errors: 410 (expired), 404 (not found), 422 (weak password).

1.7 POST /v1/auth/password/change (any authenticated)

Request: { "current_password": "...", "new_password": "..." }. Response 204.

1.8 GET /v1/auth/jwks (public)

JWKS document for the public key (RS256) or the current secret id (HS256). v1 uses HS256 — JWKS returns { "alg": "HS256", "kid": "current" } only. RS256 + JWKS becomes meaningful when we ship multi-cluster (M12 W1+) where admind and authd can't share an env var. For v1, admind verifies tokens using the same BA_AUTH_JWT_SECRET env var.

1.9 GET /health, GET /metrics

Same as other services. /health returns 200 if Postgres + Redis up. /metrics exposes ba_authd_* Prometheus metrics.

1.10 Schema additions

New tables in the same Postgres DB, schema auth:

CREATE SCHEMA auth;

CREATE TABLE auth.users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username        TEXT UNIQUE NOT NULL,
    email           TEXT UNIQUE NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT,                -- argon2id, NULL until invite accepted
    role            TEXT NOT NULL CHECK (role IN ('super_admin', 'tenant_admin')),
    company_id      TEXT REFERENCES companies(slug), -- NULL for super_admin
    status          TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended', 'locked')),
    failed_attempts INT NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX auth_users_company_id_idx ON auth.users(company_id);
CREATE INDEX auth_users_status_idx ON auth.users(status);

CREATE TABLE auth.refresh_tokens (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(refresh_token) for fast lookup
    issued_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ NOT NULL,
    rotated_to  UUID REFERENCES auth.refresh_tokens(id),  -- chain
    revoked_at  TIMESTAMPTZ
);
CREATE INDEX auth_refresh_user_idx ON auth.refresh_tokens(user_id);
CREATE INDEX auth_refresh_expires_idx ON auth.refresh_tokens(expires_at);

CREATE TABLE auth.invites (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(raw_token)
    expires_at  TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days'),
    accepted_at TIMESTAMPTZ,
    created_by  UUID NOT NULL REFERENCES auth.users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE auth.audit_log (
    id          BIGSERIAL PRIMARY KEY,
    ts          TIMESTAMPTZ NOT NULL DEFAULT now(),
    user_id     UUID REFERENCES auth.users(id),
    action      TEXT NOT NULL,           -- 'login', 'logout', 'invite_create', 'invite_accept', 'password_change', 'login_failed'
    target_user UUID REFERENCES auth.users(id),
    ip          INET,
    user_agent  TEXT,
    metadata    JSONB
);

2. admind endpoints (port 8803, JWT-gated except /health /metrics)

2.1 Companies

  • GET /v1/companies — list. Super-admin sees all. Tenant-admin sees only their own (returns 1 row). Query: ?status=&search=&limit=&cursor= Response 200:

    {
    "items": [
      {
        "id": "acme-001",
        "name": "ACME Networks",
        "slug": "acme-001",
        "status": "active",
        "fcm_shared": true,
        "rate_limit_per_sec": 1000,
        "telegram_configured": true,
        "alerts_24h": 12345,
        "created_at": "2026-01-15T10:00:00Z"
      }
    ],
    "next_cursor": null
    }
    
  • POST /v1/companies — super-admin only. Body:

    { "name": "ACME Networks", "slug": "acme-001", "rate_limit_per_sec": 1000, "fcm_shared": true }
    

    Response 201 with full company. 409 on duplicate slug.

  • GET /v1/companies/:id — scoped. 403 if tenant-admin requests other company.

  • PATCH /v1/companies/:id — scoped. Tenant-admin can only update telegram_bot_token, display_name, quiet_hours_default (whatever subset the SPEC allows). Super-admin can update anything.

  • POST /v1/companies/:id/suspend — super-admin only. 204. Writes audit.

  • POST /v1/companies/:id/activate — super-admin only. 204. Writes audit.

2.2 Sources

  • GET /v1/sources?company_id=&status=&type=&limit=&cursor= Response item:

    {
    "id": "uuid",
    "company_id": "acme-001",
    "name": "snmp-edge-01",
    "type": "snmp",
    "hmac_secret_masked": "sk_****abcd",   // never the raw secret
    "api_key_masked": "ak_****efgh",
    "rate_limit_per_sec": 100,
    "allowed_ips": ["10.0.0.0/24"],
    "topic_prefix": "snmp.acme",
    "status": "active",
    "quarantine_until": null,
    "alerts_24h": 4321,
    "created_at": "..."
    }
    
  • POST /v1/sources — body without secrets. Response includes hmac_secret_raw and api_key_raw ONCE in a one_time_secrets field (frontend must show the "save these now" modal). Server stores the hashes only.

  • PATCH /v1/sources/:id — body without secrets. To rotate, set rotate_secret=true and the response includes new one-time secrets.

  • GET /v1/sources/:id — full detail including quarantine_until.

2.3 Telegram

  • GET /v1/companies/:id/telegram

    {
    "bot_configured": true,
    "bot_username": "acme_alerts_bot",
    "invite_count": 3,
    "binding_count": 47
    }
    

    The bot token is NEVER returned. To set/rotate, use PUT.

  • PUT /v1/companies/:id/telegram — body: { "bot_token": "..." }. 204. Bot token encrypted at rest (AES-256-GCM with BA_ADMIND_MASTER_KEY).

  • GET /v1/companies/:id/telegram/invites — list of invite codes. Item: { "id": "uuid", "code": "ABC123", "status": "active"|"used"|"expired", "created_at": "...", "used_by_individual_id": "uuid" | null, "expires_at": "..." }.

  • POST /v1/companies/:id/telegram/invites — body: {}. Response 201 with the new invite. Default TTL 24h, configurable per request.

  • DELETE /v1/companies/:id/telegram/invites/:id — revoke. 204.

  • GET /v1/companies/:id/telegram/bindings — list of individual_id → telegram_user_id mappings. Item: { "individual_id", "individual_display_name", "telegram_user_id", "telegram_chat_id", "status", "last_seen_at" }.

2.4 Live tail

  • GET /v1/tail/stream (SSE) — query: ?access_token=<jwt> (EventSource workaround). Server emits event: alert\ndata: {<alert_json>}\n\n per inbound. Initial burst sends last 50 events from in-memory ring buffer (mem: tailhub). Heartbeat : ping every 15s to keep proxies alive. Auto-disconnect after 30min idle.
  • GET /v1/tail/recent?since=<rfc3339>&limit=200 — HTTP fallback for corporate proxies that strip SSE. Reads from the same ring buffer.

Alert JSON:

  {
    "id": "uuid",
    "company_id": "acme-001",
    "source_id": "uuid",
    "source_name": "snmp-edge-01",
    "severity": "warning",
    "category": "network",
    "title_key": "link_down",
    "data": { "iface": "eth0", "since": "..." },
    "dedupe_count": 3,
    "received_at": "..."
  }

2.5 DLQ (M8 — existing, frontend now uses these)

  • GET /v1/dlq?company_id=&status=&channel=&limit=&cursor= — list. Item: { "id", "alert_id", "channel", "target", "attempts", "last_error", "next_attempt_at", "created_at", "status" }.
  • GET /v1/dlq/:id — full alert payload + delivery history.
  • POST /v1/dlq/:id/replay — 204. Audit row.
  • POST /v1/dlq/:id/discard — 204. Audit row.

2.6 Audit log (new)

  • GET /v1/audit?actor=&action=&entity_type=&since=&until=&limit=&cursor= Response item:

    {
    "id": 12345,
    "ts": "...",
    "actor_id": "uuid" | null,
    "actor_username": "lrosales" | "system",
    "action": "company.suspend",
    "entity_type": "company",
    "entity_id": "acme-001",
    "ip": "1.2.3.4",
    "user_agent": "...",
    "metadata": { "reason": "non-payment" }
    }
    
  • Read-only. Filterable. No mutations from the API.

  • The same audit_log table is written from ingestd, routerd, deliverd-*, and admind directly (every service has its own INSERT). M9 already created the schema; we just add the read endpoint here.

2.7 System

  • GET /health — public. 200 if Postgres + NATS + Redis up.
  • GET /metrics — public (Prometheus). Exposes ba_admind_* counters.

3. JWT verification — design call

Default (v1): HS256, BA_AUTH_JWT_SECRET shared env var between authd and admind. Simple, works in docker-compose, works in K8s with one Secret.

When this breaks: when we run multiple admind replicas across clusters (M12 W1 carries the K8s manifests). At that point, we flip to RS256 with authd exposing the public key via /v1/auth/jwks and admind fetching + caching it. The flag is a single env var: BA_AUTH_ASYMMETRIC=true.

Why we don't ship RS256 in v1:

  • v1 runs in one cluster, one secret works.
  • Asymmetric setup means we need key rotation, JWKS caching, and a way to disable a leaked key without downtime. That's ~3 days of work that's wasted if v1 stays single-cluster.
  • We pre-wire the JWKS endpoint in authd and the verification code in admind; just don't enable it.

Interface in admind:

type JWTVerifier interface {
    Verify(token string) (Claims, error)
}

Two implementations:

  • HS256Verifier (default): reads BA_AUTH_JWT_SECRET from env.
  • JWKSVerifier (future): fetches from BA_AUTH_JWKS_URL, caches 5min.

admind picks based on BA_AUTH_ASYMMETRIC env var. No code change needed to flip.


4. OpenAPI generation

admind and authd will each export an OpenAPI 3.1 spec at runtime:

  • authd: GET /openapi.json
  • admind: GET /openapi.json

The frontend's pnpm run gen:api step fetches both, merges them into web/src/types/api.d.ts, and generates the client. CI runs this on every PR that touches cmd/**/main.go or internal/**/api.go.

This is how the frontend gets end-to-end type safety without a GraphQL or tRPC middle layer.


5. Error model

All errors return:

{
  "error": {
    "code": "company_not_found",
    "message": "Company 'acme-001' does not exist or you do not have access.",
    "request_id": "uuid"
  }
}

Standard codes:

  • unauthorized (401) — no token / invalid token
  • forbidden (403) — tenant scope violation
  • not_found (404) — entity missing
  • conflict (409) — unique constraint, duplicate slug
  • rate_limited (429) — too many requests
  • validation_failed (422) — Zod/RHF-style field errors in metadata.fields
  • internal_error (500) — server bug; request_id is the only thing the user can quote to support
  • sse_upgrade_failed (500) — only on /v1/tail/stream if NATS tail is down
  • magic_link_expired (410) — invite past 7d
  • account_locked (423) — 5 failed logins

6. Rate limits

  • authd /v1/auth/login: 5/min per IP, 10/min per username.
  • admind /v1/*: 600/min per access JWT (10/sec average).
  • admind /v1/tail/stream: 1 concurrent connection per user.

Enforced via the same Redis-based limiter used by ingestd (M9 layer 1).