diff --git a/.claude/launch.json b/.claude/launch.json index f2bdcbb..259c354 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -12,6 +12,12 @@ "runtimeExecutable": "uv", "runtimeArgs": ["run", "mdl", "serve", "-m", "demo/ibor/model", "--port", "4802"], "port": 4802 + }, + { + "name": "studio-ibor", + "runtimeExecutable": "mdl", + "runtimeArgs": ["studio", "-m", "demo/ibor/model", "--port", "4810"], + "port": 4810 } ] } diff --git a/README.md b/README.md index 494c985..a0f409c 100644 --- a/README.md +++ b/README.md @@ -864,6 +864,40 @@ the published commit rather than resuming a previous session's branch — the checkout cache is disposable, and edits live in the browser's tray until you propose. +**Identity on a shared server.** Solo, the acting user is whoever your git config +says — zero setup. For a shared deployment, put a reverse proxy in front (oauth2-proxy, +Pomerium, cloudflared, Azure App Proxy, nginx `auth_request`) that authenticates against +your IdP — OIDC, SAML, anything — and injects an identity header. Modelith trusts that +header and implements no OAuth flow itself, so SSO "just works": + +```bash +export MDL_AUTH_TRUSTED_USER_HEADER=X-Auth-Request-Email # turns header auth ON +export MDL_AUTH_PROXY_SECRET_HEADER=X-Modelith-Proxy # optional: prove it's the proxy +export MDL_AUTH_PROXY_SECRET=… # (fails closed if unset) +export MDL_AUTH_REQUIRE=1 # optional: refuse anonymous writes +``` + +The resolved identity — not a self-asserted name — becomes the commit author and the +`sme//…` branch. Safe by default: with none of this set, a raw +`X-Forwarded-User` header is ignored, so an accidentally-exposed server never trusts a +client-supplied identity. `/api/model` reports `identity: {name, email, source}` and +Studio greets "Signed in as …" instead of asking for a name. + +**Audit log.** Point Modelith at your telemetry backend and every write — including the +ones that were *refused* (which git can't show, since they never became a commit) — is +recorded as an OpenTelemetry log record: + +```bash +pip install 'modelith-dbt[audit]' # adds the OTLP exporter +export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # → Splunk/Datadog/Grafana/… +export MDL_AUDIT_LOG=/var/log/modelith/audit.jsonl # and/or a local JSONL file +``` + +Records are `{ts, identity, source, op, target, outcome}` — who did what, when, and +whether it was allowed — never the model's *contents* (those live in git for whoever has +repo access). Off entirely when neither variable is set. `GET /api/audit` tails the local +file and is admin-gated under `MDL_AUTH_REQUIRE`. + **Distributing it.** The app is a client over the API, so it can be hosted anywhere: ```bash diff --git a/canvas/src/api.ts b/canvas/src/api.ts index 3dc84cd..4b663be 100644 --- a/canvas/src/api.ts +++ b/canvas/src/api.ts @@ -132,6 +132,12 @@ export const fetchRefDiff = (base: string, head: string) => export const fetchClassification = (base = "HEAD") => get(`/api/git/classify?base=${encodeURIComponent(base)}`); +/** Route a STAGED proposal (not yet on disk): which reviewers/gates the change set + * in the tray will face. Writes nothing. */ +export const classifyStaged = ( + changes: { op: string; payload: Record }[], +) => post("/api/git/classify", { changes }); + export const fetchConflicts = (base: string) => get(`/api/git/conflicts?base=${encodeURIComponent(base)}`); diff --git a/canvas/src/sme/ProposeDialog.tsx b/canvas/src/sme/ProposeDialog.tsx index f6276f0..04792f2 100644 --- a/canvas/src/sme/ProposeDialog.tsx +++ b/canvas/src/sme/ProposeDialog.tsx @@ -10,6 +10,7 @@ import type { PendingChange } from "./SmeApp"; export function ProposeDialog({ changes, routeAdvice, + identity, user: initialUser, onUser, onDrop, @@ -19,32 +20,49 @@ export function ProposeDialog({ changes: PendingChange[]; /** route/reviewers/gates for the staged change, for the split advice (§M6) */ routeAdvice?: ClassificationDoc | null; + /** server-established identity (spec §17); when trusted, we greet instead of ask */ + identity?: { name: string; email: string; source: "proxy" | "git" | "anonymous" } | null; user?: string; onUser?: (u: string) => void; onDrop: (idx: number) => void; onClose: () => void; onProposed: (result?: ProposeResult) => void; }) { + // A proxy- or git-established identity is authoritative: the server attributes the + // commit to it and IGNORES whatever name we send, so there is nothing to ask for. + const authenticated = Boolean(identity && identity.source !== "anonymous"); const [user, setUser] = useState(initialUser || (localStorage.getItem("mdl.sme.user") ?? "")); const [title, setTitle] = useState( changes.length === 1 ? `Update ${changes[0].label.toLowerCase()}` : "Glossary updates", ); + // The branch slug defaults to the title but is separately editable, so a modeler + // can prepend a ticket id (e.g. "PROJ-123-clarify-counterparty"). It follows the + // title until the user touches it, then stops so their edit is not overwritten. + const [slug, setSlug] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const effectiveSlug = slugify(slugTouched ? slug : title); const [body, setBody] = useState(""); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const submit = () => { - if (!user.trim()) return; - localStorage.setItem("mdl.sme.user", user.trim()); - onUser?.(user.trim()); + // An authenticated user needs no name; otherwise it is required for attribution. + if (!authenticated && !user.trim()) return; + if (!authenticated) { + localStorage.setItem("mdl.sme.user", user.trim()); + onUser?.(user.trim()); + } setBusy(true); setError(null); // The PR body is generated server-side from the real model diff, so the // reviewer sees a grouped, severity-tagged summary rather than this list. + // When authenticated the server overrides `user` with the trusted identity, so + // what we send here is only the fallback for an anonymous deployment. proposeChanges({ - user: user.trim(), - slug: title, + user: authenticated ? "" : user.trim(), + // send the edited slug when the user set one, else let the title drive it + slug: effectiveSlug || title, title, body: body.trim(), changes: changes.map((c) => ({ op: c.op, payload: c.payload })), @@ -113,20 +131,47 @@ export function ProposeDialog({ ))} - + {authenticated ? ( +

+ Signed in as {identity!.name} + {identity!.email ? ` (${identity!.email})` : ""} — this proposal is + attributed to you. +

+ ) : ( + + )} +