G5: the shelter — repos, grants and version history
The plane Windy Cloud does not have. Verified 2026-08-11: routes/storage.py and
its models contain ZERO occurrences of share/permission/acl/collaborat/seat/
version/snapshot/history/revision. This fills a hole rather than bolting onto
something that already had one.
- repos: create/list/get, repo_type required (I-7), reserved slugs, Gitea
reached ONLY through the membrane client (I-1)
- grants: human identity OR agent passport, exactly one enforced by a database
CHECK constraint; agent grants expire in 90 days by default
- versions: history in words a person recognises — no 'commit', no 'branch',
no 'repository' in any user-facing string (D-9/I-9), with a test that greps
the speak strings and fails on developer vocabulary
- private repos 404 rather than 403, so a stranger cannot learn one exists
Auth: three first-class caller classes (human OIDC / agent EPT / internal
service token), NO fourth, and no bypass env var — copied deliberately from the
desktop control server, the ecosystem's best Principle-#5 artifact.
G3.6 status-code law implemented: 400 and 404 REFUSE, 429/5xx retry then REFUSE.
A sibling maps 400/429 to 'unreachable' and soft-ALLOWS, which is inducible —
an attacker who wants the check skipped only has to make it rate-limit itself.
A test asserts resolve_passport has exactly one return path.
And I-8 applied to ourselves: G3.2's JWKS verifier does not exist yet, so the
human token path REFUSES in production rather than accepting an unverified JWT.
An unverified JWT is an authentication bypass, not a shortcut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
230
api/app/auth.py
Normal file
230
api/app/auth.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Identity: humans, agents, and internal services (G3.2 / G3.6 / I-6).
|
||||
|
||||
Three caller classes, all first-class, none a bypass:
|
||||
|
||||
* **human** — account-server RS256 JWT (OIDC)
|
||||
* **agent** — Eternitas ES256 EPT
|
||||
* **service** — `X-Service-Token`, for the Cloud portal calling `/internal/*`
|
||||
|
||||
There is deliberately no fourth class and no escape hatch. The Windy Word desktop
|
||||
control server is the best Principle-#5 artifact in the ecosystem partly because
|
||||
it has **no bypass environment variable**, and that is copied here on purpose.
|
||||
|
||||
I-6 — EPT parity plus asymmetry: an agent in good standing gets exactly what a
|
||||
human of the same tier gets. Where a sibling cell silently demotes a tiered agent
|
||||
to FREE because its EPT carries no tier, we do the opposite, and a test proves it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx
|
||||
from fastapi import Header, Request
|
||||
|
||||
from api.app.config import Settings
|
||||
from api.app.errors import RepairPointer, passport_unresolvable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActorType(StrEnum):
|
||||
"""G3.7 — these three are the ONLY legal values.
|
||||
|
||||
A sibling service emits `actor_type: 'service'` into a telemetry ingest whose
|
||||
Literal allows only human|agent|system, so every batch 422s and is dropped
|
||||
with a single console warning. `service` is not spelled `service` here.
|
||||
"""
|
||||
|
||||
human = "human"
|
||||
agent = "agent"
|
||||
system = "system"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Caller:
|
||||
actor_type: ActorType
|
||||
identity_id: str | None = None
|
||||
passport: str | None = None
|
||||
band: str | None = None
|
||||
allowed_actions: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def subject(self) -> str:
|
||||
return self.identity_id or self.passport or "system"
|
||||
|
||||
|
||||
# EI_CAPABILITY_MATRIX.v1 — velocity multipliers by integrity band.
|
||||
BAND_MULTIPLIER: dict[str, float] = {
|
||||
"platinum": 10.0,
|
||||
"gold": 4.0,
|
||||
"standard": 1.0,
|
||||
"proven": 1.0,
|
||||
"watch": 0.5,
|
||||
"untrusted": 0.0, # read-only
|
||||
# Eternitas began emitting this band on 2026-07-30 and it is not in the
|
||||
# documented enum yet. Treating an unknown band as untrusted would lock out
|
||||
# every freshly hatched agent; treating it as trusted would be a hole.
|
||||
# Standard-with-no-bonus is the honest middle.
|
||||
"unproven": 1.0,
|
||||
}
|
||||
|
||||
|
||||
async def resolve_passport(settings: Settings, passport: str) -> tuple[str, tuple[str, ...]]:
|
||||
"""G3.6 — THE STATUS-CODE LAW.
|
||||
|
||||
404 and 400 REFUSE. 429 and 5xx retry with backoff, then REFUSE.
|
||||
|
||||
A sibling service maps 400 and 429 to "unreachable" and then soft-ALLOWS.
|
||||
That is a live residual bypass, because 429 is trivially inducible at
|
||||
100/min/IP: an attacker who wants the check skipped only has to make the
|
||||
check rate-limit itself. There is no code path here where an unresolvable
|
||||
passport is permitted to write.
|
||||
"""
|
||||
if not settings.eternitas_configured:
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="trust_unavailable",
|
||||
speak="We can't confirm helper IDs right now, so we didn't let that change through.",
|
||||
machine_cause="eternitas is not configured; policy is fail-closed",
|
||||
remediation_tool=None,
|
||||
)
|
||||
|
||||
url = f"{settings.eternitas_base_url}/api/v1/trust/{passport}"
|
||||
headers = {"X-API-Key": settings.eternitas_platform_api_key}
|
||||
last_status = 0
|
||||
for attempt in range(3):
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
|
||||
try:
|
||||
r = await client.get(url, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
last_status = 599
|
||||
log.warning("eternitas unreachable (attempt %s): %s", attempt + 1, exc)
|
||||
continue
|
||||
last_status = r.status_code
|
||||
if r.status_code == 200:
|
||||
body = r.json()
|
||||
return body.get("band", "unproven"), tuple(body.get("allowed_actions", []))
|
||||
if r.status_code in (400, 404):
|
||||
# Malformed or not-issued. Refuse immediately — retrying cannot help
|
||||
# and pretending it might is how a soft-allow gets written.
|
||||
break
|
||||
# 429 / 5xx: retry, then refuse. Never allow.
|
||||
raise passport_unresolvable(passport, last_status)
|
||||
|
||||
|
||||
async def get_caller(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
x_service_token: str | None = Header(default=None),
|
||||
) -> Caller:
|
||||
settings: Settings = request.app.state.settings
|
||||
|
||||
# --- internal service caller (the Cloud portal) ------------------------
|
||||
if x_service_token:
|
||||
expected = settings.service_token
|
||||
if not expected:
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="service_auth_unconfigured",
|
||||
speak="That connection isn't set up yet.",
|
||||
machine_cause="SERVICE_TOKEN is unset; refusing to accept service calls",
|
||||
remediation_tool=None,
|
||||
)
|
||||
# Constant-time compare, copied from the desktop control server's
|
||||
# control-auth pattern rather than reinvented.
|
||||
import hmac
|
||||
|
||||
if not hmac.compare_digest(x_service_token, expected):
|
||||
raise RepairPointer(
|
||||
status_code=401,
|
||||
code="service_token_invalid",
|
||||
speak="That connection isn't authorised.",
|
||||
machine_cause="X-Service-Token did not match",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return Caller(actor_type=ActorType.system, identity_id="system")
|
||||
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise RepairPointer(
|
||||
status_code=401,
|
||||
code="not_signed_in",
|
||||
speak="You'll need to sign in first.",
|
||||
machine_cause="no bearer token and no service token presented",
|
||||
remediation_tool=None,
|
||||
)
|
||||
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
|
||||
# --- agent (Eternitas EPT) --------------------------------------------
|
||||
# An EPT names its passport; the trust API is the authority on whether that
|
||||
# passport may act. We never read a band out of the token itself.
|
||||
passport = _unverified_claim(token, "passport") or _unverified_claim(token, "sub_passport")
|
||||
if passport:
|
||||
band, actions = await resolve_passport(settings, passport)
|
||||
if band.lower() == "untrusted":
|
||||
raise RepairPointer(
|
||||
status_code=403,
|
||||
code="agent_read_only",
|
||||
speak="That helper can look, but it isn't allowed to make changes yet.",
|
||||
machine_cause=f"passport {passport} band=untrusted is read-only",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return Caller(
|
||||
actor_type=ActorType.agent,
|
||||
passport=passport,
|
||||
band=band,
|
||||
allowed_actions=actions,
|
||||
)
|
||||
|
||||
# --- human (account-server RS256) -------------------------------------
|
||||
if settings.is_production and settings.require_verified_jwt:
|
||||
# I-8, applied to ourselves. G3.2's JWKS verifier is not written yet, and
|
||||
# an unverified JWT is an authentication bypass rather than a shortcut.
|
||||
# Refusing is the only honest answer until the verifier exists.
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="human_signin_not_ready",
|
||||
speak="Signing in isn't switched on yet. Nothing you have is affected.",
|
||||
machine_cause=(
|
||||
"JWKS verification (G3.2) is not implemented; refusing to accept "
|
||||
"an unverified human token in production"
|
||||
),
|
||||
remediation_tool=None,
|
||||
)
|
||||
|
||||
identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub")
|
||||
if not identity_id:
|
||||
raise RepairPointer(
|
||||
status_code=401,
|
||||
code="token_unrecognised",
|
||||
speak="We couldn't read that sign-in. Try signing in again.",
|
||||
machine_cause="token carried neither a passport nor an identity claim",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return Caller(actor_type=ActorType.human, identity_id=identity_id)
|
||||
|
||||
|
||||
def _unverified_claim(token: str, claim: str) -> str | None:
|
||||
"""Read a claim WITHOUT verifying the signature.
|
||||
|
||||
Used only to decide which verifier a token belongs to. Every path that acts
|
||||
on the result re-establishes trust independently: an agent's authority comes
|
||||
from a live Eternitas trust lookup, never from the token's own assertions.
|
||||
|
||||
⚠️ Full RS256/ES256 JWKS verification for the human path lands in G3.2's
|
||||
verifier and MUST be in place before `api.windygit.com` accepts a human
|
||||
token from outside. Until then the human path is reachable only from inside
|
||||
the tunnel, and `settings.require_verified_jwt` refuses it in production.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload)).get(claim)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
@@ -50,6 +50,17 @@ class Settings(BaseSettings):
|
||||
# ---- account-server OIDC (human identity) -----------------------------
|
||||
account_server_base_url: str = "https://account.windyword.ai"
|
||||
|
||||
# Internal callers (the Cloud portal calling /internal/*). A first-class
|
||||
# caller class, not a bypass: unset means service calls are REFUSED.
|
||||
service_token: str = ""
|
||||
|
||||
# ⚠️ FAIL-CLOSED GATE. Full RS256/ES256 JWKS verification lands in G3.2.
|
||||
# Until it does, the human token path must not be reachable in production —
|
||||
# accepting an unverified JWT is not a shortcut, it is an authentication
|
||||
# bypass. Agents are unaffected: their authority comes from a live Eternitas
|
||||
# trust lookup, not from anything the token asserts about itself.
|
||||
require_verified_jwt: bool = True
|
||||
|
||||
# ---- storage law (I-3, G4.4) ------------------------------------------
|
||||
# Git object databases MUST live on a POSIX filesystem. A test asserts this
|
||||
# path does not resolve to a network mount.
|
||||
|
||||
@@ -13,7 +13,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from api.app.buildinfo import get_build_info
|
||||
from api.app.config import get_settings
|
||||
@@ -24,7 +24,7 @@ from api.app.providers.registry import (
|
||||
GiteaProvider,
|
||||
R2Provider,
|
||||
)
|
||||
from api.app.routes import health
|
||||
from api.app.routes import health, repos
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -80,6 +80,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app.state.settings = settings
|
||||
app.state.engine = engine
|
||||
app.state.sessionmaker = (
|
||||
async_sessionmaker(engine, expire_on_commit=False) if engine is not None else None
|
||||
)
|
||||
app.state.providers = [
|
||||
DatabaseProvider(engine),
|
||||
GiteaProvider(settings),
|
||||
@@ -112,6 +115,7 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(repos.router)
|
||||
|
||||
|
||||
@app.exception_handler(RepairPointer)
|
||||
|
||||
436
api/app/routes/repos.py
Normal file
436
api/app/routes/repos.py
Normal file
@@ -0,0 +1,436 @@
|
||||
"""The shelter — repos, grants and version history (strand G5, D-8).
|
||||
|
||||
Windy Cloud today has **no sharing, no permissions and no versioning of any
|
||||
kind**: verified 2026-08-11 against `routes/storage.py` and its models, which
|
||||
contain zero occurrences of share / permission / acl / collaborat / seat /
|
||||
version / snapshot / history / revision. This plane is not a feature bolted onto
|
||||
something that already had one — it fills a hole that has never been filled.
|
||||
|
||||
D-8 also fixes the order: **permissions and history ship before the git
|
||||
protocol.** "I want someone to help me with my website" is a real problem for a
|
||||
real person, and it does not require them to know what a repository is.
|
||||
|
||||
Every string a person sees here obeys the D-9 vocabulary law: *version* and
|
||||
*save point*, never *commit*, and never the countable form of the word "Git" on
|
||||
any surface, ever. See `scripts/vocab_audit.py`, which enforces this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from api.app.auth import ActorType, Caller, get_caller
|
||||
from api.app.errors import RepairPointer
|
||||
from api.app.models.core import (
|
||||
CreatedVia,
|
||||
GrantRole,
|
||||
Repo,
|
||||
RepoGrant,
|
||||
RepoState,
|
||||
RepoType,
|
||||
RepoVersion,
|
||||
Visibility,
|
||||
)
|
||||
from api.app.services.gitea_client import GiteaClient
|
||||
|
||||
router = APIRouter(prefix="/api/v1/repos", tags=["repos"])
|
||||
|
||||
# Gitea's permission vocabulary, mapped from ours. Ours is the one users see.
|
||||
_ROLE_TO_GITEA = {
|
||||
GrantRole.owner: "admin",
|
||||
GrantRole.maintainer: "admin",
|
||||
GrantRole.writer: "write",
|
||||
GrantRole.reader: "read",
|
||||
}
|
||||
|
||||
_RESERVED_SLUGS = {
|
||||
"api", "admin", "login", "logout", "signup", "settings", "explore",
|
||||
"new", "user", "org", "repo", "assets", "static", "help", "about",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# schemas
|
||||
# --------------------------------------------------------------------------
|
||||
class CreateRepo(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
display_name: str | None = None
|
||||
description: str = ""
|
||||
# I-7 — required, never defaulted at read time, never inferred.
|
||||
repo_type: RepoType
|
||||
visibility: Visibility = Visibility.private
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _slug(cls, v: str) -> str:
|
||||
slug = "".join(c if (c.isalnum() or c in "-_") else "-" for c in v.strip().lower())
|
||||
slug = "-".join(filter(None, slug.split("-")))
|
||||
if not slug:
|
||||
raise ValueError("name must contain at least one letter or number")
|
||||
if slug in _RESERVED_SLUGS:
|
||||
raise ValueError(f"'{slug}' is reserved")
|
||||
return slug
|
||||
|
||||
|
||||
class CreateGrant(BaseModel):
|
||||
role: GrantRole
|
||||
identity_id: str | None = None
|
||||
passport: str | None = None
|
||||
|
||||
@field_validator("passport")
|
||||
@classmethod
|
||||
def _one_of(cls, v: str | None, info) -> str | None:
|
||||
if bool(info.data.get("identity_id")) == bool(v):
|
||||
# Mirrors the database CHECK constraint. Both layers, deliberately:
|
||||
# this ecosystem already has an invariant enforced only in
|
||||
# application code across two files, and a double-mint to show for it.
|
||||
raise ValueError("give exactly one of identity_id or passport")
|
||||
return v
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# helpers
|
||||
# --------------------------------------------------------------------------
|
||||
def _sessionmaker(request: Request) -> async_sessionmaker[AsyncSession]:
|
||||
maker = getattr(request.app.state, "sessionmaker", None)
|
||||
if maker is None:
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="database_unavailable",
|
||||
speak="We can't reach your projects right now. Nothing has been lost.",
|
||||
machine_cause="no database sessionmaker on app.state",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return maker
|
||||
|
||||
|
||||
def _owner_login(caller: Caller) -> str:
|
||||
"""One namespace rule for humans and agents alike (I-6)."""
|
||||
if caller.actor_type == ActorType.agent and caller.passport:
|
||||
return f"agent-{caller.passport.lower().replace('-', '')}"
|
||||
return f"u-{(caller.identity_id or 'unknown')[:24]}"
|
||||
|
||||
|
||||
async def _load_repo(session: AsyncSession, repo_id: uuid.UUID, caller: Caller) -> Repo:
|
||||
repo = (await session.execute(select(Repo).where(Repo.id == repo_id))).scalar_one_or_none()
|
||||
if repo is None or repo.state == RepoState.deleted_soft:
|
||||
raise RepairPointer(
|
||||
status_code=404,
|
||||
code="project_not_found",
|
||||
speak="We couldn't find that project.",
|
||||
machine_cause=f"repo {repo_id} not found or soft-deleted",
|
||||
remediation_tool=None,
|
||||
)
|
||||
if not await _may_read(session, repo, caller):
|
||||
# 404, not 403: a stranger should not learn that a private project exists.
|
||||
raise RepairPointer(
|
||||
status_code=404,
|
||||
code="project_not_found",
|
||||
speak="We couldn't find that project.",
|
||||
machine_cause=f"caller {caller.subject} has no grant on repo {repo_id}",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
async def _may_read(session: AsyncSession, repo: Repo, caller: Caller) -> bool:
|
||||
if caller.actor_type == ActorType.system:
|
||||
return True
|
||||
if repo.visibility == Visibility.public:
|
||||
return True
|
||||
if caller.identity_id and repo.identity_id == caller.identity_id:
|
||||
return True
|
||||
if caller.passport and repo.passport == caller.passport:
|
||||
return True
|
||||
return await _active_grant(session, repo, caller) is not None
|
||||
|
||||
|
||||
async def _active_grant(session: AsyncSession, repo: Repo, caller: Caller) -> RepoGrant | None:
|
||||
now = datetime.now(UTC)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RepoGrant).where(
|
||||
RepoGrant.repo_id == repo.id, RepoGrant.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
for g in rows:
|
||||
if g.expires_at is not None and g.expires_at <= now:
|
||||
continue # expired grants are not grants
|
||||
if caller.identity_id and g.grantee_identity_id == caller.identity_id:
|
||||
return g
|
||||
if caller.passport and g.grantee_passport == caller.passport:
|
||||
return g
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# routes
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("", status_code=201)
|
||||
async def create_repo(
|
||||
body: CreateRepo,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
settings = request.app.state.settings
|
||||
if body.repo_type.value not in settings.repo_types_enabled:
|
||||
raise RepairPointer(
|
||||
status_code=409,
|
||||
code="repo_type_not_enabled",
|
||||
speak="That kind of project isn't available yet.",
|
||||
machine_cause=(
|
||||
f"repo_type={body.repo_type.value} is not in "
|
||||
f"repo_types_enabled={list(settings.repo_types_enabled)}"
|
||||
),
|
||||
remediation_tool=None,
|
||||
)
|
||||
|
||||
gitea = GiteaClient(settings)
|
||||
owner = _owner_login(caller)
|
||||
await gitea.ensure_user(owner, f"{owner}@windygit.com")
|
||||
created = await gitea.create_repo(
|
||||
owner=owner,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
private=body.visibility != Visibility.public,
|
||||
default_branch="main",
|
||||
)
|
||||
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = Repo(
|
||||
identity_id=caller.identity_id or f"passport:{caller.passport}",
|
||||
passport=caller.passport,
|
||||
slug=body.name,
|
||||
display_name=body.display_name or body.name,
|
||||
repo_type=body.repo_type,
|
||||
gitea_repo_id=created.get("id"),
|
||||
visibility=body.visibility,
|
||||
default_branch="main",
|
||||
created_via=(
|
||||
CreatedVia.agent if caller.actor_type == ActorType.agent else CreatedVia.portal
|
||||
),
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
|
||||
return {
|
||||
"id": str(repo.id),
|
||||
"name": repo.slug,
|
||||
"repo_type": repo.repo_type.value,
|
||||
"visibility": repo.visibility.value,
|
||||
"clone_url": created.get("clone_url"),
|
||||
"speak": f"'{repo.display_name}' is ready. Everything you save is kept.",
|
||||
"state_proof": {"gitea_repo_id": repo.gitea_repo_id, "owner": owner},
|
||||
"next_actions": ["windy_git.grant_access", "windy_git.list_versions"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_repos(request: Request, caller: Annotated[Caller, Depends(get_caller)]) -> dict:
|
||||
async with _sessionmaker(request)() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Repo).where(Repo.state != RepoState.deleted_soft)
|
||||
)
|
||||
).scalars().all()
|
||||
mine = [r for r in rows if await _may_read(session, r, caller)]
|
||||
return {
|
||||
"repos": [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"name": r.slug,
|
||||
"display_name": r.display_name,
|
||||
"repo_type": r.repo_type.value,
|
||||
"visibility": r.visibility.value,
|
||||
}
|
||||
for r in mine
|
||||
],
|
||||
"count": len(mine),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{repo_id}/versions")
|
||||
async def list_versions(
|
||||
repo_id: uuid.UUID,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
"""G5.5 — history in words a person recognises.
|
||||
|
||||
Note what is absent from every user-facing string below: 'commit', 'branch',
|
||||
'repository'. A person restoring last Tuesday's work should not have to learn
|
||||
a vocabulary first (I-9, D-9).
|
||||
"""
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = await _load_repo(session, repo_id, caller)
|
||||
owner = _owner_login(caller) if repo.passport == caller.passport else None
|
||||
|
||||
gitea = GiteaClient(request.app.state.settings)
|
||||
owner = owner or f"u-{repo.identity_id[:24]}"
|
||||
commits = await gitea.list_commits(owner, repo.slug)
|
||||
|
||||
versions = [
|
||||
{
|
||||
"version": len(commits) - i,
|
||||
"id": c.get("sha"),
|
||||
"saved_at": (c.get("commit") or {}).get("author", {}).get("date"),
|
||||
"note": ((c.get("commit") or {}).get("message") or "").strip().split("\n")[0],
|
||||
"saved_by": (c.get("commit") or {}).get("author", {}).get("name"),
|
||||
}
|
||||
for i, c in enumerate(commits)
|
||||
]
|
||||
return {
|
||||
"versions": versions,
|
||||
"count": len(versions),
|
||||
"speak": (
|
||||
f"There are {len(versions)} saved versions of '{repo.display_name}'. "
|
||||
"You can go back to any of them."
|
||||
if versions
|
||||
else f"'{repo.display_name}' is empty so far."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{repo_id}/grants", status_code=201)
|
||||
async def create_grant(
|
||||
repo_id: uuid.UUID,
|
||||
body: CreateGrant,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
"""G5.3 — the thing Windy Cloud cannot do at all today.
|
||||
|
||||
A grant may name a human OR an agent passport, and agent grants expire by
|
||||
default (env: 90 days). A permanent agent credential is a standing liability
|
||||
nobody consciously chose.
|
||||
"""
|
||||
settings = request.app.state.settings
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = await _load_repo(session, repo_id, caller)
|
||||
is_owner = (caller.identity_id and repo.identity_id == caller.identity_id) or (
|
||||
caller.passport and repo.passport == caller.passport
|
||||
)
|
||||
if not is_owner and caller.actor_type != ActorType.system:
|
||||
raise RepairPointer(
|
||||
status_code=403,
|
||||
code="not_your_project",
|
||||
speak="Only the owner can share this project.",
|
||||
machine_cause=f"{caller.subject} is not the owner of {repo_id}",
|
||||
remediation_tool=None,
|
||||
)
|
||||
|
||||
expires = (
|
||||
datetime.now(UTC) + timedelta(days=settings.agent_grant_default_days)
|
||||
if body.passport
|
||||
else None
|
||||
)
|
||||
grant = RepoGrant(
|
||||
repo_id=repo.id,
|
||||
grantee_identity_id=body.identity_id,
|
||||
grantee_passport=body.passport,
|
||||
role=body.role,
|
||||
granted_by=caller.subject,
|
||||
expires_at=expires,
|
||||
)
|
||||
session.add(grant)
|
||||
await session.commit()
|
||||
await session.refresh(grant)
|
||||
grant_id, role = grant.id, grant.role
|
||||
|
||||
who = body.identity_id or body.passport
|
||||
return {
|
||||
"id": str(grant_id),
|
||||
"role": role.value,
|
||||
"grantee": who,
|
||||
"expires_at": expires.isoformat() if expires else None,
|
||||
"speak": (
|
||||
f"They can now help with '{repo.display_name}'."
|
||||
+ (" Access ends automatically in 90 days." if expires else "")
|
||||
),
|
||||
"next_actions": ["windy_git.list_grants", "windy_git.revoke_access"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{repo_id}/grants")
|
||||
async def list_grants(
|
||||
repo_id: uuid.UUID,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
now = datetime.now(UTC)
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = await _load_repo(session, repo_id, caller)
|
||||
rows = (
|
||||
await session.execute(select(RepoGrant).where(RepoGrant.repo_id == repo.id))
|
||||
).scalars().all()
|
||||
return {
|
||||
"grants": [
|
||||
{
|
||||
"id": str(g.id),
|
||||
"grantee": g.grantee_identity_id or g.grantee_passport,
|
||||
"kind": "person" if g.grantee_identity_id else "helper",
|
||||
"role": g.role.value,
|
||||
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
|
||||
"active": g.revoked_at is None
|
||||
and (g.expires_at is None or g.expires_at > now),
|
||||
}
|
||||
for g in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{repo_id}/grants/{grant_id}")
|
||||
async def revoke_grant(
|
||||
repo_id: uuid.UUID,
|
||||
grant_id: uuid.UUID,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = await _load_repo(session, repo_id, caller)
|
||||
grant = (
|
||||
await session.execute(select(RepoGrant).where(RepoGrant.id == grant_id))
|
||||
).scalar_one_or_none()
|
||||
if grant is None or grant.repo_id != repo.id:
|
||||
raise RepairPointer(
|
||||
status_code=404,
|
||||
code="grant_not_found",
|
||||
speak="We couldn't find that access to remove.",
|
||||
machine_cause=f"grant {grant_id} not on repo {repo_id}",
|
||||
remediation_tool=None,
|
||||
)
|
||||
grant.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return {"revoked": True, "speak": "That access has been removed."}
|
||||
|
||||
|
||||
@router.get("/{repo_id}")
|
||||
async def get_repo(
|
||||
repo_id: uuid.UUID,
|
||||
request: Request,
|
||||
caller: Annotated[Caller, Depends(get_caller)],
|
||||
) -> dict:
|
||||
async with _sessionmaker(request)() as session:
|
||||
repo = await _load_repo(session, repo_id, caller)
|
||||
versions = (
|
||||
await session.execute(select(RepoVersion).where(RepoVersion.repo_id == repo.id))
|
||||
).scalars().all()
|
||||
return {
|
||||
"id": str(repo.id),
|
||||
"name": repo.slug,
|
||||
"display_name": repo.display_name,
|
||||
"repo_type": repo.repo_type.value,
|
||||
"visibility": repo.visibility.value,
|
||||
"cloud_folder_ref": repo.cloud_folder_ref,
|
||||
"recorded_versions": len(versions),
|
||||
"state": repo.state.value,
|
||||
}
|
||||
171
api/app/services/gitea_client.py
Normal file
171
api/app/services/gitea_client.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""The Gitea membrane (I-1 / D-2).
|
||||
|
||||
Everything this cell needs from Gitea goes through this file and through Gitea's
|
||||
REST API. Nothing else in the codebase imports Gitea concepts, and nothing
|
||||
anywhere writes Gitea's database directly — it has its own role and its own
|
||||
database precisely so that boundary is a permission rather than a promise.
|
||||
|
||||
Keeping the dependency here is what makes D-2 affordable: Gitea ships every two
|
||||
or three months including security fixes, and a diverged fork becomes the whole
|
||||
job within a year for a small team. One file is a seam. A merged source tree is
|
||||
a marriage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from api.app.config import Settings
|
||||
from api.app.errors import RepairPointer, provider_unconfigured
|
||||
|
||||
_TIMEOUT = httpx.Timeout(20.0, connect=5.0)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._s = settings
|
||||
|
||||
def _require(self) -> None:
|
||||
if not self._s.gitea_configured:
|
||||
raise provider_unconfigured("gitea", "GITEA_ADMIN_TOKEN")
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"token {self._s.gitea_admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response:
|
||||
self._require()
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
return await client.request(
|
||||
method,
|
||||
f"{self._s.gitea_base_url}/api/v1{path}",
|
||||
headers=self._headers(),
|
||||
**kw,
|
||||
)
|
||||
|
||||
# ---- users ------------------------------------------------------------
|
||||
async def ensure_user(self, username: str, email: str) -> dict:
|
||||
"""Idempotent. Gitea is the component; our `repos` table is the truth."""
|
||||
r = await self._request("GET", f"/users/{username}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await self._request(
|
||||
"POST",
|
||||
"/admin/users",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": None,
|
||||
"must_change_password": False,
|
||||
# Humans arrive through OIDC and agents through scoped tokens.
|
||||
# Nobody gets a password on this system.
|
||||
"login_name": username,
|
||||
},
|
||||
)
|
||||
if r.status_code not in (200, 201):
|
||||
raise RepairPointer(
|
||||
status_code=502,
|
||||
code="gitea_user_create_failed",
|
||||
speak="We couldn't finish setting up that account. Nothing was lost.",
|
||||
machine_cause=f"POST /admin/users -> {r.status_code}: {r.text[:200]}",
|
||||
remediation_tool="windy_git.repair.retry_user_create",
|
||||
)
|
||||
return r.json()
|
||||
|
||||
# ---- repos ------------------------------------------------------------
|
||||
async def create_repo(
|
||||
self, owner: str, name: str, description: str, private: bool, default_branch: str
|
||||
) -> dict:
|
||||
r = await self._request(
|
||||
"POST",
|
||||
f"/admin/users/{owner}/repos",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": True,
|
||||
"default_branch": default_branch,
|
||||
# G4.5 — the LFS threshold is load-bearing, not tidiness. A large
|
||||
# blob inside a git pack cannot be resumed or offloaded, and a
|
||||
# plain push of one dies at Cloudflare's ~100s ceiling (G4A.5).
|
||||
"gitignores": "",
|
||||
},
|
||||
)
|
||||
if r.status_code not in (200, 201):
|
||||
raise RepairPointer(
|
||||
status_code=502 if r.status_code >= 500 else 409,
|
||||
code="repo_create_failed",
|
||||
speak="We couldn't create that project. Try a different name.",
|
||||
machine_cause=f"POST /admin/users/{owner}/repos -> {r.status_code}: {r.text[:200]}",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return r.json()
|
||||
|
||||
async def delete_repo(self, owner: str, name: str) -> None:
|
||||
r = await self._request("DELETE", f"/repos/{owner}/{name}")
|
||||
if r.status_code not in (204, 404):
|
||||
raise RepairPointer(
|
||||
status_code=502,
|
||||
code="repo_delete_failed",
|
||||
speak="We couldn't remove that project. It is still there and still yours.",
|
||||
machine_cause=f"DELETE /repos/{owner}/{name} -> {r.status_code}",
|
||||
remediation_tool="windy_git.repair.retry_delete",
|
||||
)
|
||||
|
||||
async def get_repo(self, owner: str, name: str) -> dict | None:
|
||||
r = await self._request("GET", f"/repos/{owner}/{name}")
|
||||
return r.json() if r.status_code == 200 else None
|
||||
|
||||
# ---- history (G5.5 / G5.6) -------------------------------------------
|
||||
async def list_commits(self, owner: str, name: str, limit: int = 50) -> list[dict]:
|
||||
r = await self._request(
|
||||
"GET", f"/repos/{owner}/{name}/commits", params={"limit": limit}
|
||||
)
|
||||
if r.status_code == 409:
|
||||
return [] # empty repo — a real state, not an error
|
||||
if r.status_code != 200:
|
||||
raise RepairPointer(
|
||||
status_code=502,
|
||||
code="history_unavailable",
|
||||
speak="We couldn't load the history for that project just now.",
|
||||
machine_cause=f"GET commits -> {r.status_code}",
|
||||
remediation_tool="windy_git.repair.rebuild_index",
|
||||
)
|
||||
return r.json()
|
||||
|
||||
# ---- collaborators (the shelter's enforcement half, G5.3) ------------
|
||||
async def put_collaborator(self, owner: str, name: str, user: str, permission: str) -> None:
|
||||
r = await self._request(
|
||||
"PUT",
|
||||
f"/repos/{owner}/{name}/collaborators/{user}",
|
||||
json={"permission": permission},
|
||||
)
|
||||
if r.status_code not in (204, 201, 200):
|
||||
raise RepairPointer(
|
||||
status_code=502,
|
||||
code="grant_apply_failed",
|
||||
speak="We couldn't share that project yet. Nobody was given access.",
|
||||
machine_cause=f"PUT collaborator -> {r.status_code}: {r.text[:200]}",
|
||||
remediation_tool="windy_git.repair.resync_grants",
|
||||
)
|
||||
|
||||
async def delete_collaborator(self, owner: str, name: str, user: str) -> None:
|
||||
r = await self._request("DELETE", f"/repos/{owner}/{name}/collaborators/{user}")
|
||||
if r.status_code not in (204, 404):
|
||||
raise RepairPointer(
|
||||
status_code=502,
|
||||
code="grant_revoke_failed",
|
||||
# The honest failure: we could not take access away. That is the
|
||||
# scarier direction, so it is stated plainly rather than softened.
|
||||
speak="We could not remove that person's access. Please try again.",
|
||||
machine_cause=f"DELETE collaborator -> {r.status_code}",
|
||||
remediation_tool="windy_git.repair.resync_grants",
|
||||
)
|
||||
|
||||
async def version(self) -> str:
|
||||
r = await self._request("GET", "/version")
|
||||
return r.json().get("version", "unknown")
|
||||
Reference in New Issue
Block a user