diff --git a/README.md b/README.md index 906f052..b0b8404 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ Existing tools each cover a fragment: scanners find problems in one repo, bots b **βœ… Verified-closed** β€” a campaign doesn't just open PRs, it **proves the fix landed**. At create it snapshots the findings it targets; after the PRs merge, re-auditing the repos confirms which are actually resolved β€” the campaign reports *"resolved 41/47"* with a per-repo breakdown, so you can see fleet posture move, not just PR counts. **Export evidence** downloads a tamper-evident compliance bundle (printable HTML report + machine-readable JSON + SHA-256 manifest) for any campaign. +**πŸ”‘ Roles & access** β€” role-based access control with named API tokens: **viewer** (read-only), **operator** (run campaigns, apply fixes), **admin** (manage tokens). Admins mint scoped, revocable tokens from the UI (the secret is shown once; only its hash is stored), and every mutation records *which* token performed it. The legacy env tokens still work (`ACTIONSPLANE_API_TOKEN` = admin, `ACTIONSPLANE_API_READ_TOKEN` = viewer); OIDC/SSO maps onto the same roles later. + **No GitHub App? Offline mode** pulls workflows/runs for any list of public repos over the public API β€” full dashboard, no webhooks. And `actionsplane audit local .` scans a local checkout as a CI gate (non-zero exit on findings). ## Quickstart diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc4593a..d3df334 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,8 @@ import { getOperateToken } from "./lib/auth"; import type { AdvisoryReport, + ApiToken, + ApiTokenCreated, AuditLogEntry, Binding, Campaign, @@ -8,6 +10,7 @@ import type { DriftDetail, Finding, FleetCost, + Identity, Job, Metrics, Mode, @@ -163,6 +166,12 @@ export const api = { }) => post("/policy/simulate", body), radar: () => get("/deprecations/radar"), advisories: () => get("/advisories/watch"), + me: () => get("/access/me"), + apiTokens: () => get("/access/tokens"), + createApiToken: (body: { name: string; role: string }) => + post("/access/tokens", body), + deleteApiToken: (id: number) => + del<{ status: string; token_id: number }>(`/access/tokens/${id}`), requireCheck: (body: { check: string; repo_ids: number[] }) => post("/governance/require-check", body), notificationEvents: () => get("/notifications/events"), diff --git a/frontend/src/components/SettingsTab.tsx b/frontend/src/components/SettingsTab.tsx index 84fae03..a2f041c 100644 --- a/frontend/src/components/SettingsTab.tsx +++ b/frontend/src/components/SettingsTab.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; import { ReposTab } from "./ReposTab"; import { NotificationsPanel } from "./NotificationsPanel"; -import { IconKey, IconShield } from "./ui"; +import { TokensPanel } from "./TokensPanel"; +import { IconShield } from "./ui"; type Section = "notifications" | "repositories" | "access" | "users"; const SECTIONS: { id: Section; label: string; hint: string }[] = [ { id: "notifications", label: "Notifications", hint: "Slack, webhook & email alerts" }, { id: "repositories", label: "Repositories", hint: "Repos ActionsPlane watches" }, - { id: "access", label: "Access (OIDC)", hint: "Single sign-on" }, - { id: "users", label: "Users", hint: "Team members & roles" }, + { id: "users", label: "API access", hint: "Tokens & roles" }, + { id: "access", label: "Single sign-on", hint: "OIDC (coming soon)" }, ]; /** A not-yet-wired settings section β€” honest about what it will configure once built. */ @@ -63,29 +64,18 @@ export function SettingsTab({ initialSection = "notifications" }: { initialSecti
{section === "notifications" && } {section === "repositories" && } + {section === "users" && } {section === "access" && ( } title="Single sign-on (OIDC)">

- Today ActionsPlane authenticates writes with a single operate token{" "} - (set via the key icon, top-right). OIDC will let your team sign in with your identity - provider (Okta, Google Workspace, Entra ID, GitHub) instead of sharing a token. + Access is managed today with role-based API tokens (see{" "} + API access). OIDC will additionally let your team sign in with your + identity provider (Okta, Google Workspace, Entra ID, GitHub) instead of holding a + token.

Planned configuration: issuer URL, client ID/secret, allowed domains, and a - group→role mapping. Track this in the roadmap under Access. -

-
- )} - {section === "users" && ( - } title="Users & roles"> -

- Once single sign-on is enabled, invited members will appear here with roles β€”{" "} - viewer (read the dashboard), operator (open fix - PRs, run campaigns), and admin (manage settings and access). -

-

- Until then, anyone with the operate token has operator access, and the read token (if - configured) grants view-only. + group→role mapping onto the same viewer/operator/admin roles.

)} diff --git a/frontend/src/components/TokensPanel.tsx b/frontend/src/components/TokensPanel.tsx new file mode 100644 index 0000000..b8563c9 --- /dev/null +++ b/frontend/src/components/TokensPanel.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { promptForToken } from "../lib/auth"; +import { useIdentity } from "../hooks/useIdentity"; +import { EmptyState, ErrorBanner, IconCheck, IconKey, IconX, TableSkeleton } from "./ui"; +import type { ApiToken } from "../types"; + +const ROLES: { id: string; label: string; hint: string }[] = [ + { id: "viewer", label: "Viewer", hint: "read-only β€” dashboards & audit, no writes" }, + { id: "operator", label: "Operator", hint: "run campaigns, apply fixes, manage notifications" }, + { id: "admin", label: "Admin", hint: "everything, incl. managing these tokens" }, +]; +const ROLE_BADGE: Record = { + admin: "running", + operator: "ok", + viewer: "neutral", +}; + +function TokenRow({ token }: { token: ApiToken }) { + const qc = useQueryClient(); + const revoke = useMutation({ + mutationFn: () => api.deleteApiToken(token.id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["api-tokens"] }), + }); + return ( +
+
+
+ + {token.name} + {token.role} +
+
+ {token.prefix}…{token.created_by ? ` Β· created by ${token.created_by}` : ""} +
+ {revoke.isError && ( +
+ {(revoke.error as Error).message} +
+ )} +
+
+ +
+
+ ); +} + +export function TokensPanel() { + const { role, isAdmin } = useIdentity(); + const qc = useQueryClient(); + const tokens = useQuery({ + queryKey: ["api-tokens"], + queryFn: api.apiTokens, + enabled: isAdmin, + }); + + const [name, setName] = useState(""); + const [newRole, setNewRole] = useState("operator"); + const [secret, setSecret] = useState(null); + const [copied, setCopied] = useState(false); + + const create = useMutation({ + mutationFn: () => api.createApiToken({ name: name.trim(), role: newRole }), + onSuccess: (res) => { + qc.invalidateQueries({ queryKey: ["api-tokens"] }); + setSecret(res.secret); + setCopied(false); + setName(""); + }, + }); + + if (!isAdmin) { + return ( +
+
+ +
+

API access & roles

+
+

+ Named API tokens carry a role β€” viewer (read-only),{" "} + operator (run campaigns & apply fixes), and{" "} + admin (manage tokens). Managing tokens requires the{" "} + admin role. +

+

+ You are currently {role ?? "unauthenticated"}.{" "} + {" "} + to manage access. +

+
+
+ ); + } + + const list = tokens.data ?? []; + + return ( +
+

+ Issue named API tokens, each with a role. The legacy env tokens still work + (ACTIONSPLANE_API_TOKEN = admin,{" "} + ACTIONSPLANE_API_READ_TOKEN = viewer). A token's secret is + shown once, on creation β€” only its hash is stored. +

+ +
+
+ setName(e.target.value)} + /> + + +
+
+ {ROLES.find((r) => r.id === newRole)?.hint} +
+ {create.isError && ( +
+ {(create.error as Error).message} +
+ )} + {secret && ( +
+
+ Copy this token now β€” it won't be shown again. +
+
+ {secret} + +
+
+ )} +
+ + {tokens.isError ? ( + + ) : tokens.isLoading ? ( + + ) : list.length === 0 ? ( + } title="No API tokens yet"> + Create one above to grant scoped, revocable access without sharing the env token. + + ) : ( +
+ {list.map((t) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useIdentity.ts b/frontend/src/hooks/useIdentity.ts new file mode 100644 index 0000000..ef6d0d4 --- /dev/null +++ b/frontend/src/hooks/useIdentity.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { useOperateToken } from "../lib/auth"; + +/** + * The caller's resolved identity + role (RBAC). Re-fetches when the operate token changes so the + * UI reflects the new role immediately after pasting a token. In tokenless "open" mode the server + * answers as a viewer; when a token is configured but none is set here, `/access/me` 401s and the + * role is simply undefined (write controls stay gated by the backend regardless). + */ +export function useIdentity() { + const token = useOperateToken(); + const q = useQuery({ + queryKey: ["me", token], + queryFn: api.me, + retry: false, + staleTime: 30_000, + }); + const role = q.data?.role; + return { + identity: q.data, + role, + isAdmin: role === "admin", + canWrite: role === "operator" || role === "admin", + }; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bcfd4ff..0dd81da 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -350,6 +350,28 @@ select.input { text-transform: none; letter-spacing: 0; transition: border-color 0.15s; } select.input:hover { border-color: var(--fg-subtle); } +input.input { + background: var(--surface-2); color: var(--fg); border: 1px solid var(--border-strong); + border-radius: var(--r-sm); padding: 7px 10px; font-size: 13px; font-family: var(--mono); + transition: border-color 0.15s; +} +input.input:focus { outline: none; border-color: var(--accent); } + +/* ---------- API tokens (RBAC) ---------- */ +.token-create { padding: 14px 16px; margin-bottom: 16px; } +.token-create-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.token-create-row input.input { flex: 1 1 220px; min-width: 0; } +.token-list { display: flex; flex-direction: column; gap: 10px; } +.token-secret { + border: 1px solid var(--ok); border-radius: var(--r-sm); padding: 10px 12px; + background: color-mix(in srgb, var(--ok) 10%, transparent); +} +.token-secret-head { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ok); } +.token-secret-value { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.token-secret-value code { + flex: 1; overflow-x: auto; white-space: nowrap; padding: 6px 8px; font-size: 12px; + background: var(--surface-3); border-radius: var(--r-sm); color: var(--fg); +} .filter-chip { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2501214..7c9f151 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -270,6 +270,25 @@ export interface AdvisoryReport { entries: AdvisoryImpact[]; } +export interface Identity { + actor: string; + role: string; +} + +export interface ApiToken { + id: number; + name: string; + role: string; + prefix: string; + created_by: string | null; + created_at: string | null; +} + +export interface ApiTokenCreated { + token: ApiToken; + secret: string; +} + export interface RequireCheckResult { repo_id: number; repo: string | null; diff --git a/migrations/versions/0015_api_tokens.py b/migrations/versions/0015_api_tokens.py new file mode 100644 index 0000000..c09b990 --- /dev/null +++ b/migrations/versions/0015_api_tokens.py @@ -0,0 +1,40 @@ +"""api_tokens: named tokens with roles (RBAC, roadmap #10) + +Backs viewer/operator/admin role-based access. Only the SHA-256 of each token is stored (unique), +never the secret. These coexist with the legacy env tokens (ACTIONSPLANE_API_TOKEN β†’ admin, +ACTIONSPLANE_API_READ_TOKEN β†’ viewer), which keep working unchanged. + +Revision ID: 0015_api_tokens +Revises: 0014_advisories +Create Date: 2026-07-25 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0015_api_tokens" +down_revision: str | None = "0014_advisories" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "api_tokens", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("prefix", sa.String(16), nullable=False), + sa.Column("created_by", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("token_hash", name="uq_api_token_hash"), + ) + + +def downgrade() -> None: + op.drop_table("api_tokens") diff --git a/src/actionsplane/api/app.py b/src/actionsplane/api/app.py index 1a235dd..2aa2428 100644 --- a/src/actionsplane/api/app.py +++ b/src/actionsplane/api/app.py @@ -23,9 +23,20 @@ from sse_starlette.sse import EventSourceResponse from actionsplane import __version__ -from actionsplane.api.auth import require_configured_operate, require_token +from actionsplane.api.auth import ( + ROLES, + Identity, + generate_token, + require_admin, + require_configured_operate, + require_identity, + require_token, +) from actionsplane.api.schemas import ( AdvisoryReportOut, + ApiTokenCreate, + ApiTokenCreated, + ApiTokenOut, AuditLogEntryOut, BindingCreate, BindingOut, @@ -41,6 +52,7 @@ FindingsPage, FleetCostOut, FleetCostRow, + IdentityOut, JobOut, MetricsOut, ModeOut, @@ -79,9 +91,11 @@ campaign_verification_counts, count_open_findings, count_open_findings_grouped, + create_api_token, create_binding, create_campaign, create_notification_channel, + delete_api_token, fleet_cost_records, get_campaign, get_notification_channel, @@ -89,6 +103,7 @@ get_repo_by_owner_name, latest_runs_for, list_all_workflows, + list_api_tokens, list_bindings, list_campaigns, list_failing_jobs, @@ -771,6 +786,80 @@ async def advisory_watch_endpoint( return AdvisoryReportOut(**asdict(report)) +# --- Access / RBAC (roadmap #10) ----------------------------------------------------------------- + + +def _token_out(t) -> ApiTokenOut: + return ApiTokenOut( + id=t.id, + name=t.name, + role=t.role, + prefix=t.prefix, + created_by=t.created_by, + created_at=t.created_at, + ) + + +@router.get("/access/me", response_model=IdentityOut) +async def whoami_endpoint(identity: Identity = Depends(require_identity)) -> IdentityOut: + """The caller's own identity + role, so the UI can reflect what they're allowed to do.""" + return IdentityOut(actor=identity.actor, role=identity.role) + + +@router.get("/access/tokens", response_model=list[ApiTokenOut]) +async def list_tokens_endpoint( + session: AsyncSession = Depends(get_session), + actor: str = Depends(require_admin), +) -> list[ApiTokenOut]: + """List API tokens (admin only). Never returns a secret β€” only the non-secret prefix.""" + return [_token_out(t) for t in await list_api_tokens(session)] + + +@router.post("/access/tokens", response_model=ApiTokenCreated, status_code=201) +async def create_token_endpoint( + body: ApiTokenCreate, + session: AsyncSession = Depends(get_session), + actor: str = Depends(require_admin), +) -> ApiTokenCreated: + """Mint a named token with a role (admin only). The plaintext secret is returned once.""" + if body.role not in ROLES: + raise HTTPException(422, f"role must be one of {', '.join(ROLES)}") + secret, token_hash, prefix = generate_token() + token = await create_api_token( + session, + name=body.name, + role=body.role, + token_hash=token_hash, + prefix=prefix, + created_by=actor, + ) + await record_write_audit( + session, + actor=actor, + action="token.create", + target=f"token:{token.id}", + detail={"name": body.name, "role": body.role}, + ) + await session.commit() + return ApiTokenCreated(token=_token_out(token), secret=secret) + + +@router.delete("/access/tokens/{token_id}") +async def delete_token_endpoint( + token_id: int, + session: AsyncSession = Depends(get_session), + actor: str = Depends(require_admin), +) -> dict: + """Revoke a token by id (admin only).""" + if not await delete_api_token(session, token_id): + raise HTTPException(404, "token not found") + await record_write_audit( + session, actor=actor, action="token.revoke", target=f"token:{token_id}" + ) + await session.commit() + return {"status": "revoked", "token_id": token_id} + + @router.get("/templates", response_model=list[TemplateOut]) async def get_templates(session: AsyncSession = Depends(get_session)) -> list[TemplateOut]: templates = await list_templates(session) diff --git a/src/actionsplane/api/auth.py b/src/actionsplane/api/auth.py index 4be426a..4d21f14 100644 --- a/src/actionsplane/api/auth.py +++ b/src/actionsplane/api/auth.py @@ -1,31 +1,55 @@ -"""API authentication + minimal RBAC (plan Β§8, Phase 5.2). - -Two bearer tokens gate ``/api/v1``: - -* **operate** (``ACTIONSPLANE_API_TOKEN``) β€” full access, required by every mutating endpoint. -* **read** (``ACTIONSPLANE_API_READ_TOKEN``, optional) β€” read endpoints only; a write attempt - with it answers 403. - -When *neither* token is configured the API is open (local-dev convenience) and every caller is -treated as the operator β€” for READS. Every GitHub-writing / config-mutating endpoint instead uses -``require_configured_operate``, which fails closed: it refuses unless ``ACTIONSPLANE_API_TOKEN`` is -both configured *and* presented, so tokenless "open" mode can never reach a write path (review 3, -N1). Configuring only the read token likewise leaves writes unreachable (no operate token exists). -Token compares are constant-time. The actor label ("operate" | "read") flows into the write-audit -log so every mutation records *which* credential performed it. This is deliberately simple β€” two -shared tokens β€” and remains the seam where real OIDC/session auth would slot in later. +"""API authentication + role-based access control (RBAC, roadmap #10). + +Every ``/api/v1`` request resolves to an :class:`Identity` β€” an audit-log ``actor`` label plus a +``role`` (viewer < operator < admin). Two credential sources feed it, checked in order: + +1. **Legacy env tokens** (unchanged, always win): ``ACTIONSPLANE_API_TOKEN`` β†’ **admin**, + ``ACTIONSPLANE_API_READ_TOKEN`` β†’ **viewer**. Compared in constant time. +2. **Named DB tokens** (``api_tokens``): a presented bearer is SHA-256'd and looked up; the row's + role is used. Admins mint/revoke these from the Access UI; only the hash is stored. + +When *no* credential is configured anywhere (no env tokens, no DB tokens) the API is open for +local dev β€” but as a **viewer**, so reads work and every write still fails closed. Roles gate the +endpoints: reads need any identity, writes need β‰₯ operator, and token administration needs admin. + +The pure env-only helpers (``classify_actor``/``token_ok``) are retained for callers/tests that +predate the DB layer; the request path goes through :func:`resolve_identity`. """ from __future__ import annotations +import hashlib import hmac +import secrets +from dataclasses import dataclass -from fastapi import Header, HTTPException +from fastapi import Depends, Header, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession from actionsplane.config import get_settings +from actionsplane.db.base import get_session +from actionsplane.db.repository import count_api_tokens, get_api_token_by_hash ACTOR_OPERATE = "operate" ACTOR_READ = "read" +ACTOR_OPEN = "open" + +ROLE_VIEWER = "viewer" +ROLE_OPERATOR = "operator" +ROLE_ADMIN = "admin" +ROLES = (ROLE_VIEWER, ROLE_OPERATOR, ROLE_ADMIN) +_RANK = {ROLE_VIEWER: 0, ROLE_OPERATOR: 1, ROLE_ADMIN: 2} + + +@dataclass(frozen=True, slots=True) +class Identity: + """Who is making the request: an audit ``actor`` label and their ``role``.""" + + actor: str + role: str + + +# --- token helpers ------------------------------------------------------------------------------- def _bearer_matches(expected: str, header: str | None) -> bool: @@ -35,20 +59,35 @@ def _bearer_matches(expected: str, header: str | None) -> bool: return hmac.compare_digest(expected, header.removeprefix("Bearer ")) +def _bearer(header: str | None) -> str | None: + if not header or not header.startswith("Bearer "): + return None + return header.removeprefix("Bearer ") + + +def hash_token(token: str) -> str: + """The SHA-256 hex digest stored/looked-up for a DB token (the secret is never persisted).""" + return hashlib.sha256(token.encode()).hexdigest() + + +def generate_token() -> tuple[str, str, str]: + """Mint a new token: returns ``(secret, hash, prefix)``. The secret is shown to the operator + exactly once; only the hash is stored, and the prefix is a non-secret display fragment.""" + secret = f"aptn_{secrets.token_urlsafe(32)}" + return secret, hash_token(secret), secret[:12] + + def token_ok(expected: str | None, header: str | None) -> bool: - """True if no token is configured (open), or the bearer header matches in constant time.""" + """True if no token is configured (open), or the bearer header matches in constant time. + Env-only helper retained for pre-RBAC callers.""" if not expected: return True return _bearer_matches(expected, header) def classify_actor(header: str | None, *, operate: str | None, read: str | None) -> str | None: - """Pure RBAC core: map a bearer header to an actor label, or None (no valid credential). - - No tokens configured β†’ open API, every caller is the operator (local dev). Otherwise the - header must match one of the configured tokens; the operate token wins if both are set to - the same value. - """ + """Pure env-only actor classifier (pre-RBAC). Retained for compatibility; the request path + uses :func:`resolve_identity`, which also consults the DB tokens.""" if not operate and not read: return ACTOR_OPERATE if operate and _bearer_matches(operate, header): @@ -58,33 +97,83 @@ def classify_actor(header: str | None, *, operate: str | None, read: str | None) return None -async def require_token(authorization: str | None = Header(default=None)) -> str: - """FastAPI dependency for read endpoints: accept either token; returns the actor label.""" +# --- identity resolution ------------------------------------------------------------------------- + + +async def resolve_identity(session: AsyncSession, authorization: str | None) -> Identity | None: + """Resolve a request to an :class:`Identity`, or ``None`` when the credential is required but + invalid. Env tokens win; then DB tokens; then open-mode (nothing configured) β†’ viewer.""" settings = get_settings() - actor = classify_actor(authorization, operate=settings.api_token, read=settings.api_read_token) - if actor is None: + presented = _bearer(authorization) + + # 1. Legacy env tokens (constant-time), highest precedence β€” no DB touch on the common path. + if ( + settings.api_token + and presented is not None + and hmac.compare_digest(settings.api_token, presented) + ): + return Identity(ACTOR_OPERATE, ROLE_ADMIN) + if ( + settings.api_read_token + and presented is not None + and hmac.compare_digest(settings.api_read_token, presented) + ): + return Identity(ACTOR_READ, ROLE_VIEWER) + + # 2. Named DB tokens. + if presented is not None: + row = await get_api_token_by_hash(session, hash_token(presented)) + if row is not None: + return Identity(f"token:{row.name}", row.role) + + # 3. Open mode: only when nothing is configured anywhere (env or DB). Reads work as a viewer; + # writes still fail closed (viewer < operator). A configured API with a bad/absent token + # lands here and returns None β†’ 401. + if ( + not settings.api_token + and not settings.api_read_token + and await count_api_tokens(session) == 0 + ): + return Identity(ACTOR_OPEN, ROLE_VIEWER) + return None + + +# --- FastAPI dependencies ------------------------------------------------------------------------ + + +async def require_identity( + session: AsyncSession = Depends(get_session), + authorization: str | None = Header(default=None), +) -> Identity: + """Resolve the caller's identity or 401. Used as the router-level read gate.""" + identity = await resolve_identity(session, authorization) + if identity is None: raise HTTPException(status_code=401, detail="missing or invalid API token") - return actor - - -async def require_operate(authorization: str | None = Header(default=None)) -> str: - """FastAPI dependency for mutating endpoints: the operate token only (read token β†’ 403).""" - actor = await require_token(authorization) - if actor != ACTOR_OPERATE: - raise HTTPException(status_code=403, detail="read-only token cannot perform writes") - return actor - - -async def require_configured_operate(authorization: str | None = Header(default=None)) -> str: - """FastAPI dependency for GitHub-writing / config-mutating endpoints β€” fail closed (N1). - - Stricter than ``require_operate``: it also refuses when *no* operate token is configured at - all. Tokenless "open" mode is a convenience for reads only; a mutating endpoint must never be - reachable without ``ACTIONSPLANE_API_TOKEN`` both configured and presented. Returns "operate". - """ - if not get_settings().api_token: - raise HTTPException( - status_code=403, - detail="writes require ACTIONSPLANE_API_TOKEN configured (open mode is read-only)", - ) - return await require_operate(authorization) + return identity + + +async def require_token(identity: Identity = Depends(require_identity)) -> str: + """Read gate: any valid identity. Returns the actor label.""" + return identity.actor + + +def require_role(minimum: str): + """Build a dependency that requires ``minimum`` role or higher; returns the actor label.""" + + async def _dep(identity: Identity = Depends(require_identity)) -> str: + if _RANK[identity.role] < _RANK[minimum]: + raise HTTPException( + status_code=403, + detail=f"this action requires the '{minimum}' role (you have '{identity.role}')", + ) + return identity.actor + + return _dep + + +# Write endpoints need β‰₯ operator; token administration needs admin. ``require_configured_operate`` +# keeps its name so the (many) existing write endpoints need no change β€” it now means "β‰₯ operator", +# which in open mode is unreachable (the open identity is a viewer), preserving fail-closed writes. +require_operator = require_role(ROLE_OPERATOR) +require_configured_operate = require_operator +require_admin = require_role(ROLE_ADMIN) diff --git a/src/actionsplane/api/schemas.py b/src/actionsplane/api/schemas.py index 9589147..a9e8436 100644 --- a/src/actionsplane/api/schemas.py +++ b/src/actionsplane/api/schemas.py @@ -14,6 +14,43 @@ _OPERATION_RE = re.compile(r"^[A-Za-z0-9._-]+$") +class IdentityOut(BaseModel): + """Who the caller is, for the UI to reflect their role (RBAC, roadmap #10).""" + + actor: str + role: str + + +class ApiTokenOut(BaseModel): + """A named API token, minus its secret (only a non-secret prefix is ever returned).""" + + id: int + name: str + role: str + prefix: str + created_by: str | None = None + created_at: datetime | None = None + + +class ApiTokenCreate(BaseModel): + name: str + role: str + + @field_validator("name") + @classmethod + def _name_nonempty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("token name is required") + return v.strip()[:255] + + +class ApiTokenCreated(BaseModel): + """The create response: the stored metadata plus the plaintext secret, shown exactly once.""" + + token: ApiTokenOut + secret: str + + class RepoOut(BaseModel): id: int owner: str diff --git a/src/actionsplane/db/models.py b/src/actionsplane/db/models.py index e39f5a1..147f310 100644 --- a/src/actionsplane/db/models.py +++ b/src/actionsplane/db/models.py @@ -298,6 +298,26 @@ class NotificationSent(Base): sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) +class ApiToken(Base): + """A named API token with a role (RBAC, roadmap #10). The token secret is never stored β€” only + its SHA-256 hash (``token_hash``, unique) β€” so a leaked DB row can't be replayed; ``prefix`` is + a short non-secret fragment shown in the UI to tell tokens apart. ``role`` is one of + viewer/operator/admin. These sit alongside the legacy env tokens (``ACTIONSPLANE_API_TOKEN`` β†’ + admin, ``ACTIONSPLANE_API_READ_TOKEN`` β†’ viewer), which keep working unchanged.""" + + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True + ) + name: Mapped[str] = mapped_column(String(255)) + role: Mapped[str] = mapped_column(String(16)) # viewer | operator | admin + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + prefix: Mapped[str] = mapped_column(String(16)) # non-secret display fragment + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + class Advisory(Base): """A GitHub security advisory (GHSA) for the ``actions`` ecosystem, polled from the global advisory DB (roadmap #7). One row per ``(ghsa_id, package)`` β€” a single advisory can name diff --git a/src/actionsplane/db/repository.py b/src/actionsplane/db/repository.py index ac3133f..a719674 100644 --- a/src/actionsplane/db/repository.py +++ b/src/actionsplane/db/repository.py @@ -33,6 +33,7 @@ from actionsplane.db.models import ( Advisory, + ApiToken, AuditFinding, Campaign, CampaignFinding, @@ -885,6 +886,53 @@ async def list_advisories(session: AsyncSession) -> list[Advisory]: return list((await session.scalars(stmt)).all()) +async def create_api_token( + session: AsyncSession, + *, + name: str, + role: str, + token_hash: str, + prefix: str, + created_by: str | None, +) -> ApiToken: + """Insert a named API token (RBAC). Only the hash is stored; the caller shows the secret.""" + token = ApiToken( + name=name, + role=role, + token_hash=token_hash, + prefix=prefix, + created_by=created_by, + created_at=datetime.now(UTC), + ) + session.add(token) + await session.flush() + return token + + +async def list_api_tokens(session: AsyncSession) -> list[ApiToken]: + """All API tokens, newest first (never includes the secret β€” only prefix/role/metadata).""" + stmt = select(ApiToken).order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + return list((await session.scalars(stmt)).all()) + + +async def get_api_token_by_hash(session: AsyncSession, token_hash: str) -> ApiToken | None: + """Look a presented token up by its SHA-256 hash (the auth path).""" + return ( + await session.scalars(select(ApiToken).where(ApiToken.token_hash == token_hash)) + ).first() + + +async def count_api_tokens(session: AsyncSession) -> int: + """How many API tokens exist β€” lets auth tell 'open mode' from 'configured, deny'.""" + return int((await session.scalar(select(func.count()).select_from(ApiToken))) or 0) + + +async def delete_api_token(session: AsyncSession, token_id: int) -> bool: + """Revoke (delete) a token by id. Returns True if a row was removed.""" + result = await session.execute(delete(ApiToken).where(ApiToken.id == token_id)) + return bool(result.rowcount) + + async def list_notification_channels( session: AsyncSession, *, enabled_only: bool = False ) -> list[NotificationChannel]: diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 371ba01..2599c66 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -1,144 +1,241 @@ -"""Tests for the API bearer-token gate + read/operate RBAC (Phase 5.2).""" +"""API auth + RBAC (roadmap #10): env tokens, DB tokens, roles, and the token-admin endpoints.""" from __future__ import annotations -from types import SimpleNamespace - import pytest from fastapi import HTTPException +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles +from actionsplane.api import auth as auth_mod from actionsplane.api.auth import ( ACTOR_OPERATE, ACTOR_READ, + ROLE_ADMIN, + ROLE_OPERATOR, + ROLE_VIEWER, classify_actor, + generate_token, + hash_token, require_configured_operate, - require_operate, + require_identity, + require_role, require_token, + resolve_identity, token_ok, ) +from actionsplane.db.base import Base +from actionsplane.db.repository import create_api_token + + +@compiles(JSONB, "sqlite") +def _jsonb_as_json_on_sqlite(element, compiler, **kw): + return "JSON" + + +class _Settings: + def __init__(self, api_token=None, api_read_token=None): + self.api_token = api_token + self.api_read_token = api_read_token + + +@pytest.fixture +async def session(): + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as s: + yield s + await engine.dispose() -def test_open_when_no_token_configured(): - assert token_ok(None, None) is True - assert token_ok(None, "Bearer anything") is True +# --- pure env-only helpers (retained) ------------------------------------------------------------ -def test_enforced_when_configured(): +def test_token_ok_open_and_enforced(): + assert token_ok(None, "Bearer anything") is True # unset β†’ open assert token_ok("secret", "Bearer secret") is True assert token_ok("secret", "Bearer wrong") is False - assert token_ok("secret", None) is False - assert token_ok("secret", "secret") is False # missing "Bearer " prefix + assert token_ok("secret", "secret") is False # needs the Bearer prefix -def test_classify_open_api_treats_everyone_as_operator(): - # no tokens configured β†’ local-dev open mode, caller is the operator - assert classify_actor(None, operate=None, read=None) == ACTOR_OPERATE - assert classify_actor("Bearer whatever", operate=None, read=None) == ACTOR_OPERATE +def test_classify_actor_env_only(): + kw = {"operate": "op", "read": "ro"} + assert classify_actor("Bearer op", **kw) == ACTOR_OPERATE + assert classify_actor("Bearer ro", **kw) == ACTOR_READ + assert classify_actor("Bearer nope", **kw) is None + assert classify_actor(None, operate=None, read=None) == ACTOR_OPERATE # open -def test_classify_operate_and_read_tokens(): - kw = {"operate": "op-tok", "read": "ro-tok"} - assert classify_actor("Bearer op-tok", **kw) == ACTOR_OPERATE - assert classify_actor("Bearer ro-tok", **kw) == ACTOR_READ - assert classify_actor("Bearer wrong", **kw) is None - assert classify_actor(None, **kw) is None - assert classify_actor("op-tok", **kw) is None # missing "Bearer " prefix +def test_generate_and_hash_token(): + secret, digest, prefix = generate_token() + assert secret.startswith("aptn_") + assert digest == hash_token(secret) and len(digest) == 64 + assert secret.startswith(prefix) and len(prefix) == 12 -def test_classify_read_only_config_is_fail_closed(): - # Only the read token configured: reads work, but nothing can ever classify as operate, - # so the write paths are unreachable rather than open. - assert classify_actor("Bearer ro-tok", operate=None, read="ro-tok") == ACTOR_READ - assert classify_actor("Bearer ro-tok", operate="op-tok", read=None) is None +# --- resolve_identity: env β†’ DB β†’ open ----------------------------------------------------------- -@pytest.fixture -def rbac_settings(monkeypatch): - """Point the auth dependencies at a fixed operate+read token pair.""" - monkeypatch.setattr( - "actionsplane.api.auth.get_settings", - lambda: SimpleNamespace(api_token="op-tok", api_read_token="ro-tok"), +async def test_env_tokens_map_to_admin_and_viewer(session, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op", "ro")) + admin = await resolve_identity(session, "Bearer op") + viewer = await resolve_identity(session, "Bearer ro") + assert (admin.actor, admin.role) == (ACTOR_OPERATE, ROLE_ADMIN) + assert (viewer.actor, viewer.role) == (ACTOR_READ, ROLE_VIEWER) + # a wrong token with env configured is rejected (not open) + assert await resolve_identity(session, "Bearer nope") is None + assert await resolve_identity(session, None) is None + + +async def test_db_token_resolves_to_its_role(session, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op", None)) + secret, digest, prefix = generate_token() + await create_api_token( + session, name="ci-bot", role=ROLE_OPERATOR, token_hash=digest, prefix=prefix, created_by="t" ) + await session.commit() + ident = await resolve_identity(session, f"Bearer {secret}") + assert ident.role == ROLE_OPERATOR + assert ident.actor == "token:ci-bot" + + +async def test_open_mode_is_viewer_only(session, monkeypatch): + # nothing configured anywhere β†’ open, but as a viewer (reads ok, writes blocked) + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings(None, None)) + ident = await resolve_identity(session, None) + assert ident.role == ROLE_VIEWER + # once a DB token exists, the API is no longer open: an anonymous request is rejected + secret, digest, prefix = generate_token() + await create_api_token( + session, name="x", role=ROLE_ADMIN, token_hash=digest, prefix=prefix, created_by="t" + ) + await session.commit() + assert await resolve_identity(session, None) is None + assert (await resolve_identity(session, f"Bearer {secret}")).role == ROLE_ADMIN -async def test_require_token_accepts_either(rbac_settings): - assert await require_token("Bearer op-tok") == ACTOR_OPERATE - assert await require_token("Bearer ro-tok") == ACTOR_READ - with pytest.raises(HTTPException) as exc: - await require_token("Bearer nope") - assert exc.value.status_code == 401 +# --- dependencies + role gating ------------------------------------------------------------------ -async def test_require_operate_rejects_read_token_with_403(rbac_settings): - assert await require_operate("Bearer op-tok") == ACTOR_OPERATE +async def test_require_token_and_role_gates(session, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op", "ro")) + admin = await require_identity(session=session, authorization="Bearer op") + viewer = await require_identity(session=session, authorization="Bearer ro") + assert await require_token(admin) == ACTOR_OPERATE + # operator gate: admin passes, viewer 403s + assert await require_configured_operate(admin) == ACTOR_OPERATE with pytest.raises(HTTPException) as exc: - await require_operate("Bearer ro-tok") - assert exc.value.status_code == 403 # authenticated, but not authorized to write + await require_configured_operate(viewer) + assert exc.value.status_code == 403 + # admin gate: operator identity is refused + op_only = auth_mod.Identity("token:ci", ROLE_OPERATOR) with pytest.raises(HTTPException) as exc: - await require_operate(None) - assert exc.value.status_code == 401 + await require_role(ROLE_ADMIN)(op_only) + assert exc.value.status_code == 403 -async def test_require_configured_operate_with_token(rbac_settings): - # Operate token configured + presented β†’ allowed; read token β†’ 403; nothing β†’ 401. - assert await require_configured_operate("Bearer op-tok") == ACTOR_OPERATE +async def test_require_identity_401_without_credential(session, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op", None)) with pytest.raises(HTTPException) as exc: - await require_configured_operate("Bearer ro-tok") - assert exc.value.status_code == 403 - with pytest.raises(HTTPException) as exc: - await require_configured_operate(None) + await require_identity(session=session, authorization=None) assert exc.value.status_code == 401 -async def test_require_configured_operate_fails_closed_without_token(monkeypatch): - # Tokenless "open" mode: a write dependency must refuse regardless of the header (N1). - monkeypatch.setattr( - "actionsplane.api.auth.get_settings", - lambda: SimpleNamespace(api_token=None, api_read_token=None), - ) - for header in (None, "Bearer anything"): - with pytest.raises(HTTPException) as exc: - await require_configured_operate(header) - assert exc.value.status_code == 403 +# --- end-to-end through the app (hermetic sqlite) ------------------------------------------------ -def test_rbac_enforced_through_the_app(rbac_settings): - """End-to-end through FastAPI: read token reads, cannot write; operate token passes the - RBAC gate (the offline-sync endpoint then 409s on offline mode being off β€” after auth).""" +@pytest.fixture +async def client(monkeypatch): + """A TestClient with get_session backed by an isolated sqlite DB (no external DB needed).""" from fastapi.testclient import TestClient from actionsplane.api.app import app - - with TestClient(app) as client: - ro = {"Authorization": "Bearer ro-tok"} - op = {"Authorization": "Bearer op-tok"} - assert client.get("/api/v1/mode", headers=ro).status_code == 200 - assert client.get("/api/v1/mode").status_code == 401 # no token at all - assert client.post("/api/v1/offline/sync", headers=ro).status_code == 403 - assert client.post("/api/v1/offline/sync", headers=op).status_code == 409 - # the audit trail itself is operator-level information - assert client.get("/api/v1/audit-log", headers=ro).status_code == 403 - - -def test_writes_fail_closed_in_tokenless_mode(monkeypatch): - """Tokenless 'open' mode: reads work without a credential, but every GitHub-writing endpoint - answers 403 β€” the write path is unreachable without a configured operate token (N1).""" - monkeypatch.setattr( - "actionsplane.api.auth.get_settings", - lambda: SimpleNamespace(api_token=None, api_read_token=None), + from actionsplane.db.base import get_session + + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async def _override(): + async with maker() as s: + yield s + + app.dependency_overrides[get_session] = _override + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + await engine.dispose() + + +def test_rbac_enforced_through_the_app(client, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op-tok", "ro-tok")) + ro = {"Authorization": "Bearer ro-tok"} + op = {"Authorization": "Bearer op-tok"} + assert client.get("/api/v1/mode", headers=ro).status_code == 200 + assert client.get("/api/v1/mode").status_code == 401 # no token at all + assert client.post("/api/v1/offline/sync", headers=ro).status_code == 403 # viewer can't write + assert client.post("/api/v1/offline/sync", headers=op).status_code == 409 # passes auth + assert client.get("/api/v1/audit-log", headers=ro).status_code == 403 + + +def test_writes_fail_closed_in_tokenless_mode(client, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings(None, None)) + assert client.get("/api/v1/mode").status_code == 200 # reads stay open (viewer) + for path in ("/api/v1/offline/sync", "/api/v1/runs/1/rerun"): + assert client.post(path).status_code == 403, path + assert client.get("/api/v1/audit-log").status_code == 403 + + +def test_token_admin_endpoints(client, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op-tok", "ro-tok")) + admin = {"Authorization": "Bearer op-tok"} + viewer = {"Authorization": "Bearer ro-tok"} + + # viewer cannot administer tokens + assert client.get("/api/v1/access/tokens", headers=viewer).status_code == 403 + + # admin mints a token; the secret is returned exactly once + resp = client.post( + "/api/v1/access/tokens", + headers=admin, + json={"name": "deploy-bot", "role": "operator"}, ) - from fastapi.testclient import TestClient - - from actionsplane.api.app import app - - with TestClient(app) as client: - assert client.get("/api/v1/mode").status_code == 200 # reads stay open - for path in ( - "/api/v1/offline/sync", - "/api/v1/runs/1/rerun", - "/api/v1/repos/1/sarif/upload", - ): - assert client.post(path).status_code == 403, path # writes fail closed - # the write-audit trail is operator-grade, so it's gated like a write even for reads - # (review 4, NEW-11): world-unreadable in tokenless open mode. - assert client.get("/api/v1/audit-log").status_code == 403 + assert resp.status_code == 201 + created = resp.json() + secret = created["secret"] + assert secret.startswith("aptn_") + assert "secret" not in created["token"] # only metadata in the token object + token_id = created["token"]["id"] + + # it shows up in the list (masked β€” prefix only, never the secret) + listing = client.get("/api/v1/access/tokens", headers=admin).json() + assert any(t["id"] == token_id and t["role"] == "operator" for t in listing) + assert all(secret not in str(t) for t in listing) + + # the new operator token can write but not administer tokens + op_bot = {"Authorization": f"Bearer {secret}"} + assert client.post("/api/v1/offline/sync", headers=op_bot).status_code == 409 # write allowed + assert client.get("/api/v1/access/tokens", headers=op_bot).status_code == 403 # admin-only + + # /access/me reflects the role + me = client.get("/api/v1/access/me", headers=op_bot).json() + assert me["role"] == "operator" and me["actor"] == "token:deploy-bot" + + # revoke it; afterwards it no longer authenticates + assert client.delete(f"/api/v1/access/tokens/{token_id}", headers=admin).status_code == 200 + assert client.post("/api/v1/offline/sync", headers=op_bot).status_code == 401 + assert client.delete(f"/api/v1/access/tokens/{token_id}", headers=admin).status_code == 404 + + +def test_create_token_rejects_bad_role(client, monkeypatch): + monkeypatch.setattr(auth_mod, "get_settings", lambda: _Settings("op-tok", None)) + resp = client.post( + "/api/v1/access/tokens", + headers={"Authorization": "Bearer op-tok"}, + json={"name": "x", "role": "superuser"}, + ) + assert resp.status_code == 422