diff --git a/README.md b/README.md index 3089aab..c45ed52 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,42 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.57.0 — API tokens + +**Settings → API tokens** issues long-lived bearer tokens for scripts and CI, so +automation stops needing a password and an hourly login: + +```bash +curl -H "Authorization: Bearer sp_…" https://stackpilot.example/api/stacks +curl -X POST -H "Authorization: Bearer sp_…" \ + https://stackpilot.example/api/stacks/immich/update +``` + +Each token can be revoked on its own, without signing anyone's browser out. + +**Scopes.** A token is either *read-only* or *full access*, and the choice is +independent of who created it — an admin can hand a monitoring script a token +that cannot change a thing. A token never outranks its owner either: demote the +account and its tokens drop to read-only with it, disable the account and they +stop working. + +**A token cannot make itself permanent.** Creating tokens and creating user +accounts both now require a signed-in session, so a leaked CI credential cannot +quietly mint a second one that survives the first being revoked. This is the one +behaviour change for existing installs: if you were scripting user creation, it +needs a login rather than a token. + +**Stored hashed.** Unlike registry passwords — which have to be handed back to a +registry — a token is only ever compared against, so only a SHA-256 of it is +kept. It is shown once, when you create it, and cannot be recovered; the UI +keeps a readable prefix (`sp_AbCdEfGh…`) so you can tell rows apart in the list +and in the audit log. Tokens record when they were last used, so a stale one is +easy to spot. + +Optional expiry in days, `token.create` / `token.revoke` in the audit log, and +the whole section is admin-only. The live WebSocket streams (logs, terminal, +deploy console) still need a session token — a CI job has no use for them. + ## Upgrading to 0.56.0 — private registries, and a silent bug fixed **Update checks on private images were lying.** StackPilot asks the registry for @@ -353,6 +389,10 @@ it is what your saved destination credentials are encrypted with. compose** converter. - **Dashboard** — system resource bar, stack grid with quick actions, and a recent-activity audit feed. +- **API tokens** — long-lived bearer tokens for scripts and CI, read-only or + full access, revocable one at a time, stored hashed and shown once. A token + can never do more than the account that owns it, and cannot create tokens or + users — those need a signed-in session. - **Private registries** — a login per registry (Settings → Private registries) used both by StackPilot's own update checks and by `docker compose pull`, which it reaches through a generated `DOCKER_CONFIG`. Passwords are encrypted @@ -904,6 +944,17 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache) GET /api/dashboard/summary (containers, uptime series, ops activity) ``` +### API token endpoints + +``` +GET /api/auth/tokens (admin; the token itself is never returned) +POST /api/auth/tokens ({"name","scope":"read"|"admin","expires_in_days"?}) +DELETE /api/auth/tokens/{id} (revoke) +``` + +Authenticate with `Authorization: Bearer sp_…` on any REST endpoint. Managing +tokens and users is deliberately excluded — those need a session. + ### Private registry endpoints ``` @@ -929,6 +980,10 @@ GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache) - The Docker socket is only ever touched by the backend process; it is never proxied to the browser. +- API tokens are stored as a SHA-256 hash and shown exactly once. SHA-256 rather + than bcrypt on purpose: a token is 256 bits of `secrets` output, so guessing + is not the threat bcrypt's cost would be defending against — and that cost + would land on every API request. - Registry passwords and backup-destination credentials are encrypted at rest (Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that carries them for `compose pull` is written 0600 inside the data volume. diff --git a/backend/auth.py b/backend/auth.py index 9f56014..50a2304 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Optional -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext @@ -13,6 +13,7 @@ from sqlmodel import Session, select from config import settings from database import get_session from models.user import User +from services import api_token_service pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) @@ -131,6 +132,7 @@ def users_exist(session: Session) -> bool: def get_current_user( + request: Request, token: Optional[str] = Depends(oauth2_scheme), session: Session = Depends(get_session), ) -> User: @@ -140,6 +142,24 @@ def get_current_user( detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) + # An API token is not a JWT and must not be fed to the decoder — it is + # recognised by its prefix and looked up instead. + if api_token_service.looks_like_token(token): + resolved = api_token_service.resolve(session, token) + if not resolved: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="This API token is not valid (unknown, expired or revoked)", + ) + row, user = resolved + api_token_service.touch(session, row) + # Stashed rather than folded into the User: mutating the role on a + # session-attached row would be written back to the database the next + # time anything commits that user. + request.state.api_token = row + return user + + request.state.api_token = None payload = decode_token(token, "access") user = resolve_token_user(session, payload) if not user: @@ -150,6 +170,11 @@ def get_current_user( return user +def current_api_token(request: Request): + """The API token this request was authenticated with, if any.""" + return getattr(request.state, "api_token", None) + + def require_admin_role(user: User) -> User: """Role check split out so the WebSocket routes can reuse it.""" if user.role != "admin": @@ -160,5 +185,39 @@ def require_admin_role(user: User) -> User: return user -def require_admin(user: User = Depends(get_current_user)) -> User: - return require_admin_role(user) +def require_admin( + request: Request, user: User = Depends(get_current_user) +) -> User: + require_admin_role(user) + row = current_api_token(request) + if row and api_token_service.effective_role(row, user) != "admin": + # The owner is an admin but this token was issued read-only, which is + # the whole point of handing one to a monitoring script. + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This API token is read-only", + ) + return user + + +def require_session( + request: Request, user: User = Depends(get_current_user) +) -> User: + """An interactive session, not an API token. + + Guards the routes that mint or revoke credentials — API tokens and user + accounts. A leaked CI token should be able to do the job it was issued for, + not quietly grant itself permanent access that outlives its own revocation. + """ + if current_api_token(request) is not None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This action requires a signed-in session, not an API token", + ) + return user + + +def require_admin_session( + request: Request, user: User = Depends(require_admin) +) -> User: + return require_session(request, user) diff --git a/backend/main.py b/backend/main.py index 5a71bd7..ec5238d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -33,6 +33,7 @@ from routers import ( stacks, system, templates, + tokens, volumes, ws, ) @@ -132,6 +133,7 @@ async def docker_error_handler(_request: Request, exc: DockerError): app.include_router(auth.router) app.include_router(stacks.router) +app.include_router(tokens.router) app.include_router(registries.router) app.include_router(secrets.router) app.include_router(containers.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 5cb23be..c6d03b9 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,4 +1,5 @@ """SQLModel table models. Importing this package registers all tables.""" +from models.api_token import ApiToken from models.audit import AuditLog from models.auto_update import AutoUpdate from models.backup_destination import BackupDestination @@ -12,5 +13,5 @@ from models.user import User __all__ = [ "User", "Stack", "AuditLog", "Setting", "Webhook", "BackupDestination", "BackupSchedule", "AutoUpdate", - "StackLock", "ImageStatus", "LoginAttempt", "Registry", + "StackLock", "ImageStatus", "LoginAttempt", "Registry", "ApiToken", ] diff --git a/backend/models/api_token.py b/backend/models/api_token.py new file mode 100644 index 0000000..215fe45 --- /dev/null +++ b/backend/models/api_token.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +#: What a token is allowed to do. "read" matches the read-only user role even +#: when the owner is an admin, so a monitoring script can be handed a token that +#: cannot change anything. +SCOPES = ["read", "admin"] + + +class ApiToken(SQLModel, table=True): + """A long-lived bearer token for scripts and CI, owned by a user. + + Only a hash is stored — the token itself is shown once, when it is created, + and cannot be recovered afterwards. ``prefix`` is the readable front of the + token (``sp_`` plus eight characters); it identifies the row in the UI and + in the audit log without being enough to authenticate with. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + name: str + prefix: str = Field(index=True, unique=True) + token_hash: str + scope: str = Field(default="read") + #: The account the token acts as. Its role caps the token's scope, and a + #: disabled account disables its tokens. + user_id: int = Field(index=True) + expires_at: Optional[datetime] = None + last_used_at: Optional[datetime] = None + created_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class ApiTokenCreate(SQLModel): + name: str + scope: str = "read" + #: Days until it expires. None means it does not. + expires_in_days: Optional[int] = None + + +class ApiTokenRead(SQLModel): + id: int + name: str + prefix: str + scope: str + username: str + expires_at: Optional[datetime] + last_used_at: Optional[datetime] + created_at: datetime + expired: bool + + +class ApiTokenCreated(ApiTokenRead): + """The create response, and the only time the token itself is returned.""" + + token: str diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 8dcedb2..50ac0a0 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -231,7 +231,7 @@ def _ip(request: Request) -> str: @router.get("/users", response_model=list[UserRead]) def list_users( session: Session = Depends(get_session), - _admin: User = Depends(auth_mod.require_admin), + _admin: User = Depends(auth_mod.require_admin_session), ) -> list[User]: return session.exec(select(User).order_by(User.id)).all() @@ -241,7 +241,7 @@ def create_user( body: UserCreate, request: Request, session: Session = Depends(get_session), - admin: User = Depends(auth_mod.require_admin), + admin: User = Depends(auth_mod.require_admin_session), ) -> User: if not body.username.strip() or not body.password: raise HTTPException(status_code=400, detail="Username and password required") @@ -269,7 +269,7 @@ def update_user( body: UserUpdate, request: Request, session: Session = Depends(get_session), - admin: User = Depends(auth_mod.require_admin), + admin: User = Depends(auth_mod.require_admin_session), ) -> User: user = session.get(User, user_id) if not user: @@ -316,7 +316,7 @@ def delete_user( user_id: int, request: Request, session: Session = Depends(get_session), - admin: User = Depends(auth_mod.require_admin), + admin: User = Depends(auth_mod.require_admin_session), ) -> dict: user = session.get(User, user_id) if not user: diff --git a/backend/routers/tokens.py b/backend/routers/tokens.py new file mode 100644 index 0000000..956a237 --- /dev/null +++ b/backend/routers/tokens.py @@ -0,0 +1,104 @@ +"""API tokens for scripts and CI. + +Managing tokens needs a signed-in session, never another API token: a leaked CI +credential should be able to do the job it was issued for, not mint itself a +second one that survives the first being revoked. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session, select + +from auth import require_admin_session +from database import get_session +from models.api_token import ( + SCOPES, + ApiToken, + ApiTokenCreate, + ApiTokenCreated, + ApiTokenRead, +) +from models.user import User +from services import api_token_service, audit_service + +router = APIRouter(prefix="/api/auth/tokens", tags=["auth"]) + + +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _to_read(row: ApiToken, session: Session) -> ApiTokenRead: + owner = session.get(User, row.user_id) + return ApiTokenRead( + id=row.id, + name=row.name, + prefix=row.prefix, + scope=row.scope, + username=owner.username if owner else "(deleted)", + expires_at=row.expires_at, + last_used_at=row.last_used_at, + created_at=row.created_at, + expired=api_token_service.is_expired(row), + ) + + +@router.get("", response_model=list[ApiTokenRead]) +def list_tokens( + session: Session = Depends(get_session), + _user: User = Depends(require_admin_session), +) -> list[ApiTokenRead]: + rows = session.exec(select(ApiToken).order_by(ApiToken.created_at.desc())).all() + return [_to_read(r, session) for r in rows] + + +@router.post("", response_model=ApiTokenCreated, status_code=201) +def create_token( + body: ApiTokenCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin_session), +) -> ApiTokenCreated: + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="A name is required") + if body.scope not in SCOPES: + raise HTTPException( + status_code=400, detail=f"Scope must be one of {', '.join(SCOPES)}" + ) + if body.expires_in_days is not None and body.expires_in_days < 1: + raise HTTPException(status_code=400, detail="Expiry must be at least a day") + + row, token = api_token_service.mint( + session, + name=name, + user=user, + scope=body.scope, + expires_in_days=body.expires_in_days, + ) + audit_service.record( + session, user=user.username, action="token.create", target=row.prefix, + detail=f"{name} ({row.scope})", ip=_ip(request), + ) + # The only time the token itself is ever returned. + return ApiTokenCreated(**_to_read(row, session).model_dump(), token=token) + + +@router.delete("/{token_id}") +def revoke_token( + token_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin_session), +) -> dict: + row = session.get(ApiToken, token_id) + if not row: + raise HTTPException(status_code=404, detail=f"Token {token_id} not found") + prefix = row.prefix + session.delete(row) + session.commit() + audit_service.record( + session, user=user.username, action="token.revoke", target=prefix, + detail=row.name, ip=_ip(request), + ) + return {"ok": True} diff --git a/backend/services/api_token_service.py b/backend/services/api_token_service.py new file mode 100644 index 0000000..cf3ce44 --- /dev/null +++ b/backend/services/api_token_service.py @@ -0,0 +1,134 @@ +"""Long-lived API tokens for scripts and CI. + +A session token is the wrong credential for automation: it expires in an hour, +it is minted by typing a password, and revoking it means signing every one of +that person's devices out. So a CI job gets its own credential, which can be +revoked on its own, is capped to read-only if that is all it needs, and shows up +in the audit log as itself. + +**Only a hash is stored.** Unlike a registry password — which has to be handed +back to the registry, so it is encrypted and recoverable — a token is only ever +compared against. It is shown once at creation and cannot be recovered, which is +the difference between leaking the database and leaking everything it protects. + +The hash is a plain SHA-256 and deliberately not bcrypt. Bcrypt is slow on +purpose, to make guessing low-entropy human passwords expensive; a token is 256 +bits of ``secrets`` output, where guessing is not the threat and the cost would +instead land on every single API request. +""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlmodel import Session, select + +from models.api_token import ApiToken +from models.user import User + +#: Marks a StackPilot token at a glance — in a log, in a CI settings page, or to +#: a secret scanner. It is also how the auth dependency tells a token from a JWT +#: without trying to decode it. +PREFIX = "sp_" + +#: How stale last_used_at may get before a request writes it again. Without a +#: floor this would be a database write on every single API call. +_TOUCH_INTERVAL = timedelta(minutes=5) + + +def looks_like_token(value: str) -> bool: + return (value or "").startswith(PREFIX) + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _aware(value: Optional[datetime]) -> Optional[datetime]: + """SQLite hands back naive datetimes; compare them as UTC.""" + if value is None: + return None + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +def is_expired(row: ApiToken, now: Optional[datetime] = None) -> bool: + expires = _aware(row.expires_at) + if expires is None: + return False + return expires <= (now or datetime.now(timezone.utc)) + + +def mint( + session: Session, + *, + name: str, + user: User, + scope: str = "read", + expires_in_days: Optional[int] = None, +) -> tuple[ApiToken, str]: + """Create a token. Returns the row and the secret, which is shown once.""" + token = PREFIX + secrets.token_urlsafe(32) + expires_at = ( + datetime.now(timezone.utc) + timedelta(days=expires_in_days) + if expires_in_days + else None + ) + row = ApiToken( + name=name, + prefix=token[: len(PREFIX) + 8], + token_hash=_hash(token), + scope=scope if scope in ("read", "admin") else "read", + user_id=user.id, + expires_at=expires_at, + ) + session.add(row) + session.commit() + session.refresh(row) + return row, token + + +def resolve(session: Session, token: str) -> Optional[tuple[ApiToken, User]]: + """The token row and its owner, or None if it cannot be used. + + None covers every reason equally — unknown, expired, owner disabled — so a + caller cannot learn which by watching the responses. + """ + if not looks_like_token(token): + return None + prefix = token[: len(PREFIX) + 8] + row = session.exec(select(ApiToken).where(ApiToken.prefix == prefix)).first() + if not row: + return None + # Constant-time, so a wrong token cannot be narrowed down by timing. + if not secrets.compare_digest(row.token_hash, _hash(token)): + return None + if is_expired(row): + return None + user = session.get(User, row.user_id) + if not user or not user.is_active: + return None + return row, user + + +def effective_role(row: ApiToken, user: User) -> str: + """What this token may do, which is never more than its owner may. + + A token keeps working when its owner is demoted, but drops to read-only with + them — the alternative is an admin token outliving the admin. + """ + if row.scope == "admin" and user.role == "admin": + return "admin" + return "user" + + +def touch(session: Session, row: ApiToken) -> None: + """Record that the token was used, at most once every few minutes.""" + now = datetime.now(timezone.utc) + last = _aware(row.last_used_at) + if last and now - last < _TOUCH_INTERVAL: + return + row.last_used_at = now + session.add(row) + session.commit() diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py new file mode 100644 index 0000000..f2a9bc2 --- /dev/null +++ b/backend/tests/test_api_tokens.py @@ -0,0 +1,320 @@ +"""API tokens: long-lived credentials for scripts and CI. + +A token is a credential that outlives a session and is handed to a machine, so +the things worth pinning down are the ones that go wrong quietly: + +* only a hash is stored, and the token is returned exactly once, +* a read-scoped token really is read-only, even when its owner is an admin, +* a token never outranks its owner — demote or disable the account and the + token follows, +* and a token cannot mint another one, so a leak cannot be made permanent. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlmodel import Session, delete, select + + +@pytest.fixture +def svc(db): + from services import api_token_service + + return api_token_service + + +@pytest.fixture(autouse=True) +def clean_tokens(db): + from database import engine + from models.api_token import ApiToken + + with Session(engine) as session: + session.exec(delete(ApiToken)) + session.commit() + yield + with Session(engine) as session: + session.exec(delete(ApiToken)) + session.commit() + + +@pytest.fixture +def owner(db): + """A throwaway admin, so tests can disable or demote it freely.""" + from database import engine + from models.user import User + import auth as auth_mod + + with Session(engine) as session: + existing = session.exec(select(User).where(User.username == "token-owner")).first() + if existing: + session.delete(existing) + session.commit() + user = User( + username="token-owner", + hashed_password=auth_mod.hash_password("pw"), + role="admin", + ) + session.add(user) + session.commit() + session.refresh(user) + user_id = user.id + yield user_id + with Session(engine) as session: + row = session.get(User, user_id) + if row: + session.delete(row) + session.commit() + + +def _mint(svc, owner_id: int, scope: str = "admin", expires_in_days=None) -> str: + from database import engine + from models.user import User + + with Session(engine) as session: + user = session.get(User, owner_id) + _row, token = svc.mint( + session, name=f"test-{scope}", user=user, scope=scope, + expires_in_days=expires_in_days, + ) + return token + + +def _as(client, token: str): + """A tiny client that authenticates with a raw bearer token.""" + + class _Client: + def get(self, url, **kw): + return client.get(url, headers={"Authorization": f"Bearer {token}"}, **kw) + + def post(self, url, **kw): + return client.post(url, headers={"Authorization": f"Bearer {token}"}, **kw) + + def delete(self, url, **kw): + return client.delete(url, headers={"Authorization": f"Bearer {token}"}, **kw) + + return _Client() + + +# --------------------------------------------------------------------------- # +# What is stored +# --------------------------------------------------------------------------- # + + +def test_the_token_is_stored_only_as_a_hash(svc, owner): + from database import engine + from models.api_token import ApiToken + + token = _mint(svc, owner) + with Session(engine) as session: + row = session.exec(select(ApiToken)).one() + assert token not in row.token_hash + assert row.token_hash != token + # The readable front is kept so the UI can name the row, and it is far too + # short to authenticate with. + assert token.startswith(row.prefix) + assert len(row.prefix) < len(token) / 2 + + +def test_tokens_are_unique_and_prefixed(svc, owner): + first, second = _mint(svc, owner), _mint(svc, owner) + assert first != second + assert first.startswith("sp_") and second.startswith("sp_") + + +def test_a_tampered_token_does_not_resolve(svc, owner): + from database import engine + + token = _mint(svc, owner) + with Session(engine) as session: + assert svc.resolve(session, token) is not None + assert svc.resolve(session, token[:-1] + "x") is None + assert svc.resolve(session, "sp_totallymadeupvalue") is None + # A JWT must not be mistaken for one. + assert svc.resolve(session, "eyJhbGciOiJIUzI1NiJ9.e30.x") is None + + +# --------------------------------------------------------------------------- # +# Using one +# --------------------------------------------------------------------------- # + + +def test_a_token_authenticates_like_a_session(client, svc, owner): + response = _as(client, _mint(svc, owner)).get("/api/auth/me") + assert response.status_code == 200 + assert response.json()["username"] == "token-owner" + + +def test_a_read_token_cannot_change_anything(client, svc, owner): + """The owner is an admin; the token is not. That is the point of scopes.""" + api = _as(client, _mint(svc, owner, scope="read")) + assert api.get("/api/stacks").status_code == 200 + denied = api.post("/api/registries", json={"host": "ghcr.io", "username": "a", "password": "b"}) + assert denied.status_code == 403 + assert "read-only" in denied.json()["detail"] + + +def test_an_admin_token_may_act(client, svc, owner): + api = _as(client, _mint(svc, owner, scope="admin")) + created = api.post( + "/api/registries", + json={"host": "quay.io", "username": "a", "password": "b"}, + ) + assert created.status_code == 201, created.text + api.delete(f"/api/registries/{created.json()['id']}") + + +def test_using_a_token_records_when(client, svc, owner): + from database import engine + from models.api_token import ApiToken + + _as(client, _mint(svc, owner)).get("/api/auth/me") + with Session(engine) as session: + assert session.exec(select(ApiToken)).one().last_used_at is not None + + +def test_the_last_used_write_is_throttled(svc, owner, monkeypatch): + """Otherwise every API call is also a database write.""" + from database import engine + from models.api_token import ApiToken + + token = _mint(svc, owner) + with Session(engine) as session: + row = svc.resolve(session, token)[0] + svc.touch(session, row) + first = row.last_used_at + svc.touch(session, row) + assert row.last_used_at == first + + # Far enough in the past and it is written again. + row.last_used_at = datetime.now(timezone.utc) - timedelta(hours=1) + session.add(row) + session.commit() + svc.touch(session, row) + assert session.exec(select(ApiToken)).one().last_used_at != first + + +# --------------------------------------------------------------------------- # +# A token never outranks its owner +# --------------------------------------------------------------------------- # + + +def test_a_demoted_owner_drops_the_token_to_read_only(client, svc, owner): + from database import engine + from models.user import User + + token = _mint(svc, owner, scope="admin") + with Session(engine) as session: + user = session.get(User, owner) + user.role = "user" + session.add(user) + session.commit() + + api = _as(client, token) + assert api.get("/api/stacks").status_code == 200 + assert api.post("/api/registries", json={"host": "x.io", "username": "a", "password": "b"}).status_code == 403 + + +def test_a_disabled_owner_kills_the_token(client, svc, owner): + from database import engine + from models.user import User + + token = _mint(svc, owner) + with Session(engine) as session: + user = session.get(User, owner) + user.is_active = False + session.add(user) + session.commit() + + assert _as(client, token).get("/api/auth/me").status_code == 401 + + +def test_an_expired_token_is_refused(client, svc, owner): + from database import engine + from models.api_token import ApiToken + + token = _mint(svc, owner, expires_in_days=1) + with Session(engine) as session: + row = session.exec(select(ApiToken)).one() + row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) + session.add(row) + session.commit() + + response = _as(client, token).get("/api/auth/me") + assert response.status_code == 401 + assert "not valid" in response.json()["detail"] + + +def test_a_token_with_no_expiry_does_not_expire(svc, owner): + from database import engine + from models.api_token import ApiToken + + _mint(svc, owner) + with Session(engine) as session: + assert svc.is_expired(session.exec(select(ApiToken)).one()) is False + + +# --------------------------------------------------------------------------- # +# A token cannot make itself permanent +# --------------------------------------------------------------------------- # + + +def test_a_token_cannot_mint_another_token(client, svc, owner): + api = _as(client, _mint(svc, owner, scope="admin")) + response = api.post("/api/auth/tokens", json={"name": "second", "scope": "admin"}) + assert response.status_code == 403 + assert "signed-in session" in response.json()["detail"] + + +def test_a_token_cannot_create_a_user(client, svc, owner): + api = _as(client, _mint(svc, owner, scope="admin")) + response = api.post( + "/api/auth/users", json={"username": "backdoor", "password": "x", "role": "admin"} + ) + assert response.status_code == 403 + + +def test_a_token_cannot_even_list_tokens(client, svc, owner): + assert _as(client, _mint(svc, owner, scope="admin")).get("/api/auth/tokens").status_code == 403 + + +# --------------------------------------------------------------------------- # +# Through the API +# --------------------------------------------------------------------------- # + + +def test_create_shows_the_token_once_and_never_again(as_admin): + created = as_admin.post("/api/auth/tokens", json={"name": "ci", "scope": "read"}) + assert created.status_code == 201, created.text + token = created.json()["token"] + assert token.startswith("sp_") + + listed = as_admin.get("/api/auth/tokens") + assert token not in listed.text + assert '"token"' not in listed.text + assert listed.json()[0]["prefix"] == token[:11] + + +def test_revoking_a_token_stops_it_working(client, as_admin): + created = as_admin.post("/api/auth/tokens", json={"name": "ci", "scope": "read"}).json() + api = _as(client, created["token"]) + assert api.get("/api/auth/me").status_code == 200 + + assert as_admin.delete(f"/api/auth/tokens/{created['id']}").status_code == 200 + assert api.get("/api/auth/me").status_code == 401 + + +def test_a_bad_scope_or_empty_name_is_refused(as_admin): + assert as_admin.post("/api/auth/tokens", json={"name": "x", "scope": "root"}).status_code == 400 + assert as_admin.post("/api/auth/tokens", json={"name": " ", "scope": "read"}).status_code == 400 + assert ( + as_admin.post( + "/api/auth/tokens", json={"name": "x", "scope": "read", "expires_in_days": 0} + ).status_code + == 400 + ) + + +def test_the_read_only_role_cannot_manage_tokens(as_user): + assert as_user.get("/api/auth/tokens").status_code == 403 + assert as_user.post("/api/auth/tokens", json={"name": "x"}).status_code == 403 diff --git a/backend/version.py b/backend/version.py index e185534..688e1e9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.56.0" +APP_VERSION = "0.57.0" diff --git a/frontend/package.json b/frontend/package.json index 5c19988..deed214 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.56.0", + "version": "0.57.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/tokens.ts b/frontend/src/api/tokens.ts new file mode 100644 index 0000000..dc51498 --- /dev/null +++ b/frontend/src/api/tokens.ts @@ -0,0 +1,28 @@ +import api from "./client"; + +const base = "/api/auth/tokens"; + +export interface ApiToken { + id: number; + name: string; + /** The readable front of the token — enough to identify it, not to use it. */ + prefix: string; + scope: "read" | "admin"; + username: string; + expires_at: string | null; + last_used_at: string | null; + created_at: string; + expired: boolean; +} + +/** The create response: the only time the token itself is ever returned. */ +export interface ApiTokenCreated extends ApiToken { + token: string; +} + +export const tokensApi = { + list: () => api.get(base).then((r) => r.data), + create: (body: { name: string; scope: "read" | "admin"; expires_in_days?: number }) => + api.post(base, body).then((r) => r.data), + revoke: (id: number) => api.delete(`${base}/${id}`).then((r) => r.data), +}; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3b6cea9..1ea7ad0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -14,6 +14,9 @@ import { CalendarClock, Play, KeyRound, + Terminal, + Copy, + Check, } from "lucide-react"; import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; @@ -29,6 +32,7 @@ import { type RegistryCredentials, type RegistryInput, } from "@/api/registries"; +import { tokensApi, type ApiToken, type ApiTokenCreated } from "@/api/tokens"; import { schedulesApi, type BackupSchedule } from "@/api/schedules"; import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; @@ -58,6 +62,7 @@ export function Settings() { + ); @@ -989,6 +994,218 @@ function WebhookForm({ /* Users */ /* -------------------------------------------------------------------------- */ +/* -------------------------------------------------------------------------- */ +/* API tokens */ +/* -------------------------------------------------------------------------- */ + +function ApiTokensSection() { + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["api-tokens"], queryFn: tokensApi.list }); + const [adding, setAdding] = useState(false); + /** Held until dismissed: this is the only time the token is ever shown. */ + const [created, setCreated] = useState(null); + const invalidate = () => qc.invalidateQueries({ queryKey: ["api-tokens"] }); + + return ( +
+ }>API tokens +
+ {created && setCreated(null)} />} + {isLoading ? ( + + ) : ( + data?.map((t) => ) + )} + {data?.length === 0 && !adding && !created && ( + +

+ No API tokens. Create one to drive StackPilot from a script or CI + job without handing over a password — each token can be revoked on + its own, and a read-only one cannot change anything. +

+
+ )} + {adding ? ( + { + setAdding(false); + setCreated(token); + invalidate(); + }} + onCancel={() => setAdding(false)} + /> + ) : ( + + )} +
+
+ ); +} + +function NewTokenCard({ + created, + onDismiss, +}: { + created: ApiTokenCreated; + onDismiss: () => void; +}) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(created.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard access can be refused (no HTTPS, denied permission); the + // token is on screen to select by hand either way. + toast.error("Could not copy — select the token and copy it manually"); + } + }; + + return ( + +
+

Copy “{created.name}” now

+

+ This is the only time the token is shown. It is stored hashed, so it + cannot be recovered — if you lose it, revoke it and create another. +

+
+
+ + {created.token} + + +
+
+ +
+
+ ); +} + +function TokenRow({ token, onChange }: { token: ApiToken; onChange: () => void }) { + const revoke = useMutation({ + mutationFn: () => tokensApi.revoke(token.id), + onSuccess: () => { + toast.success("Token revoked"); + onChange(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + return ( + +
+
+ {token.name} + {token.scope === "admin" ? "full access" : "read-only"} + {token.expired && expired} +
+

+ {token.prefix}… · {token.username} +

+

+ {token.last_used_at + ? `last used ${relativeTime(token.last_used_at)}` + : "never used"} + {token.expires_at && ` · expires ${relativeTime(token.expires_at)}`} +

+
+ +
+ ); +} + +function TokenForm({ + onDone, + onCancel, +}: { + onDone: (token: ApiTokenCreated) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [scope, setScope] = useState<"read" | "admin">("read"); + const [expiry, setExpiry] = useState(""); + + const save = useMutation({ + mutationFn: () => + tokensApi.create({ + name: name.trim(), + scope, + expires_in_days: expiry ? Number(expiry) : undefined, + }), + onSuccess: onDone, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + return ( + +
+ + + +
+

+ The token acts as you. It can never do more than your account can, and + it cannot create tokens or user accounts — those need a signed-in + session, so a leaked token cannot make itself permanent. +

+
+ + +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Users */ +/* -------------------------------------------------------------------------- */ + function UsersSection() { const qc = useQueryClient(); const me = useAuthStore((s) => s.user);