From f14b3d392a1ee203e27dfe5ebc97e69e3194145e Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Thu, 10 Sep 2026 00:48:11 +1000 Subject: [PATCH 1/6] Add IdP-agnostic identity seam for enterprise adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acting user was a self-asserted free-text string (ProposeBody.user / localStorage) threaded into a Co-authored-by trailer — anyone could claim to be anyone, the classic enterprise blocker. Add a thin identity layer (spec §17): - mdl_server/identity.py resolves who a request is from: a trusted reverse-proxy header (enterprise SSO via oauth2-proxy/Pomerium/cloudflared/nginx/Azure), the model dir's git config (solo — zero config), or anonymous. - Safe by default: a raw header is NEVER trusted unless the operator names it via MDL_AUTH_TRUSTED_USER_HEADER; an optional shared-secret gate (fail-closed when the value is missing) proves the request came through the proxy. - /api/model now reports an identity block so the Studio app can greet the user. - propose attributes the commit to the SERVER-resolved identity, overriding the self-asserted string when trusted; the branch prefix and Co-authored-by trailer both derive from the effective author. Falls back to the client string only when anonymous, so nothing regresses for a solo user with no git config. No OAuth/OIDC/SAML flow, token vault, user store, or sessions — the proxy already did the IdP dance; authorization stays in git (branch protection + CODEOWNERS). Also records the Principal-Architect/PM enterprise-adoption review as Part II of modelith-spec.md (competitive positioning, ranked gaps, identity spec, M7-M9). Tests: 19 new identity unit tests (resolution, safe defaults, secret gate, author precedence) + propose integration tests proving a spoofed body.user cannot override a proxy-authenticated author. 525 passed, 1 skipped; ruff clean. --- .claude/launch.json | 6 + modelith-spec.md | 167 ++++++++++++++++ packages/server/src/mdl_server/app.py | 32 ++- packages/server/src/mdl_server/git_api.py | 75 +++++-- packages/server/src/mdl_server/identity.py | 215 +++++++++++++++++++++ packages/server/tests/test_diff_api.py | 16 +- packages/server/tests/test_identity.py | 197 +++++++++++++++++++ packages/server/tests/test_propose.py | 97 +++++++++- 8 files changed, 777 insertions(+), 28 deletions(-) create mode 100644 packages/server/src/mdl_server/identity.py create mode 100644 packages/server/tests/test_identity.py 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/modelith-spec.md b/modelith-spec.md index 29a3d07..3fe84bb 100644 --- a/modelith-spec.md +++ b/modelith-spec.md @@ -574,3 +574,170 @@ The PR comment format matters more than it sounds. Render added/dropped/type-cha - No warehouse-side execution beyond optional profiling for inference. - No LLM in the critical path. AI assists naming, term matching and description drafting; it never decides a relationship or writes to a catalog without a recorded human verdict. - No canvas before M5. + +--- + +# Part II — Enterprise adoption (Principal-Architect / PM review) + +*Added on the `experimental` branch. Part I above is the build spec for the tool +itself and is not changed by this part — where Part I is good it stays. This part +adds only what enterprise adoption requires, sequenced so a solo user never pays for +a feature only a shared deployment needs.* + +## 15. Where Modelith stands, and against whom + +The defensible wedge, confirmed by a competitive scan (erwin, ER/Studio, SqlDBM, +Ellie.ai, Hackolade, Azimutt; dbt Canvas + Semantic Layer/MetricFlow, Cube; Collibra, +Alation, Atlan, Purview, DataHub, OpenMetadata, Unity Catalog; TopBraid, Stardog, +PoolParty; Stoplight, Backstage): + +> **An ontology-anchored logical/ER model held in git as plain YAML, that +> forward-generates contract-enforced dbt, reverse-engineers dbt back, and classifies +> the drift between them — a round-trip no single competitor offers.** + +Each pillar alone is contested. The combination is not. Two consequences shape +priorities: + +- **The sharpest threat is dbt Canvas** (GA, dbt Enterprise-gated). It already nails + the visual-editor → *Commit → open a PR* workflow that Modelith pitches for the + steward persona, and it is bundled for teams already on dbt Cloud. Modelith must + lead with what Canvas lacks: a real **logical/ER + conceptual layer**, **ontology + alignment**, **reverse + drift classification**, and life **outside the dbt Cloud + walled garden** (any warehouse, any git host, self-hostable, offline-capable). +- **"The model lives in git, every tool is a client" is now table stakes**, not a + differentiator — Stoplight (API specs), Backstage (`catalog-info.yaml`), and dbt + itself all do it. Modelith's git-nativeness is necessary, not sufficient; the + round-trip is the moat. + +**Do not chase** the things the platforms above own and a git-YAML design tool +structurally cannot: automated estate-wide metadata crawling, column-level *runtime* +lineage from query logs, and data-quality/observability. Modelith captures *intended +design and contract relationships*; it should emit lineage (it already plans +OpenLineage, §9.6) and let a governance platform sit above it, not try to become one. + +## 16. The enterprise-adoption gap, ranked + +The single hardest procurement blocker is **identity**. Modelith today has *no* +authentication: the acting user is a self-asserted free-text string +(`ProposeBody.user`, browser `localStorage["mdl.sme.user"]`) threaded into a git +`Co-authored-by` trailer. Anyone can claim to be anyone. Every commercial competitor +clears this bar; a solo user does not need it. That tension — enterprise needs +verified identity, solo needs zero friction — is resolved in §17. + +Ranked, most-blocking first. Each is a *requirement* enterprises impose; the roadmap +in §18 sequences them. + +1. **Verified identity (SSO-compatible).** Who authored a proposal must be + trustworthy, without Modelith implementing any IdP flow. → **§17, specified, build + now.** +2. **Authorization / RBAC.** Not everyone may edit everything. Modelith's honest + answer is *git already does this* — branch protection + CODEOWNERS decide who + merges — but that must be documented as the RBAC story and surfaced, not left + implicit. → §18 M8. +3. **Audit trail for administrative events.** Git history records *model* changes; + it does not record *who was let in, what config changed, who was denied*. A + thin append-only audit log of identity/permission/config events. → §18 M9. +4. **SOC 2 Type II readiness.** Process/organizational, not code — but the code must + not block it (deterministic builds, no telemetry-by-default, documented data + flows). Called out so it is tracked, not built. → §18 M9. +5. **Lineage emission enterprises can consume.** OpenLineage is already planned + (§9.6); ship it and integrate with Atlan/Collibra/DataHub rather than competing on + observed lineage. → existing M5, reaffirmed. + +## 17. Identity — a thin, IdP-agnostic seam + +**Principle: solo pays nothing.** With nothing configured, identity resolves to the +user's own git config (exactly like a normal `git commit`), and if even that is +absent, to anonymous. There is no login, no account, no server-side user store, ever +(this restates and does not weaken §13.5 / §14: *state stays in git*). + +**Principle: trust the proxy, not the client.** The near-universal enterprise pattern +is a reverse proxy (oauth2-proxy, Pomerium, cloudflared, nginx `auth_request`, Azure +App Proxy) that authenticates against the org IdP — OIDC, SAML, whatever — and injects +an identity header. Modelith trusts *that header* and implements **no** OAuth/OIDC/SAML +flow itself. This makes every IdP "just work" and keeps Modelith out of token +validation, key rotation, redirect URIs, and four provider code paths. + +### 17.1 Resolution + +A single module `mdl_server/identity.py`: + +``` +Identity(name, email, source) source ∈ {"proxy", "git", "anonymous"} +IdentityPolicy.from_env(os.environ) resolved once at create_app time, immutable +resolve_identity(headers, policy, model_dir) -> Identity +``` + +Precedence: **trusted header** (only if the operator opted in) → **git config** of the +model dir → **anonymous**. A request with a raw `X-Forwarded-User` and no configured +policy resolves to *anonymous*, never proxy — the safe default. + +### 17.2 Configuration (env vars, namespaced `MDL_AUTH_*`) + +| Env var | Purpose | Default | +|---|---|---| +| `MDL_AUTH_TRUSTED_USER_HEADER` | Header the trusted proxy sets with the user's email/id (e.g. `X-Auth-Request-Email`). **Its presence is the master switch that turns header auth ON.** | unset ⇒ OFF | +| `MDL_AUTH_TRUSTED_NAME_HEADER` | Optional display-name header. | unset ⇒ derive from email local-part | +| `MDL_AUTH_PROXY_SECRET_HEADER` | Optional header the proxy must present to prove it *is* the proxy. | unset | +| `MDL_AUTH_PROXY_SECRET` | Expected value of that header (constant-time compared). If the secret *header* is set but this is empty ⇒ **fail closed** (header auth off). | unset | + +Config is deployment/environment data, so it lives in env vars read inside +`create_app` — `serve()` and the CLI need no signature change. A separate optional +hardening switch, `MDL_AUTH_REQUIRE=1` (or `mdl studio --require-identity`), makes +*write* endpoints refuse an anonymous identity; ship it as a small follow-up, not in +the first cut. + +### 17.3 Effects + +- `/api/model` gains an `identity` block `{name, email, source}`. The Studio app reads + it: when `source != "anonymous"` it shows "signed in as {name}" and stops asking for + a name / stops sending `user`. +- **Propose author precedence** (`_effective_author`): `proxy` overrides any + client-supplied `user` (the whole enterprise point); `git` uses the git-config + identity (correct solo behavior); `anonymous` falls back to `body.user` (no + regression). The proposal branch name and the `Co-authored-by` trailer are built + from the *effective* author, not the self-asserted string. +- **Reads are never gated by identity.** Identity is a write-time concern; a read-only + viewer (catalog browse, the week-3 gate) needs none. `/api/model` may still carry + the block so the UI can greet the viewer. + +### 17.4 Explicit non-goals for identity + +No OAuth/OIDC/SAML flow, no JWT/JWKS verification, no token vault, no user database, +no roles table, no sessions/cookies/login page, and no change to *push credentials* +(identity ≠ credentials — the server keeps pushing with its own git credentials, a +deploy key the operator sets up). Authorization stays in git (branch protection + +CODEOWNERS). Keeping the seam this thin is what preserves the solo promise. + +## 18. Roadmap deltas (append to §12 milestones) + +**M7, identity seam.** `identity.py`; `IdentityPolicy` from `MDL_AUTH_*`; +`/api/model` `identity` block; propose author precedence; Studio "signed in as". +*Accept:* a POST `/propose` carrying a trusted user header and a *different* +self-asserted `user` produces a commit attributed to the header identity, not the +spoofed string; with no policy configured, propose still attributes to git config and +all existing tests stay green. + +**M8, authorization story.** Document git-native RBAC (branch protection + +CODEOWNERS) as the authorization model; surface the reviewers/gates a proposal will +face *before* submit (the route/reviewers panel already exists); optional +`MDL_AUTH_REQUIRE` write gate. +*Accept:* a proposal preview names the reviewers and required checks its PR will +trigger, derived from the repo's CODEOWNERS. + +**M9, audit + SOC 2 readiness.** Append-only audit log of identity/permission/config +events (distinct from git model history); a documented data-flow / no-egress +statement; deterministic-build evidence. +*Accept:* every write endpoint emits an audit record `{ts, identity, source, op, +target}`; the log is append-only and queryable; a security reviewer can trace any +administrative event. + +## 19. Anti-goals (append to §14) + +- **Modelith is not a data catalog or observability platform.** It emits lineage + (OpenLineage) and definitions for one; it does not crawl the estate, compute + runtime lineage, or run data-quality checks. +- **Modelith implements no identity-provider protocol.** It trusts a proxy header or + git config. SSO is the proxy's job. +- **No server-side user or permission store.** Identity is resolved per request; + authorization lives in git. diff --git a/packages/server/src/mdl_server/app.py b/packages/server/src/mdl_server/app.py index e0ab6cb..fe062e9 100644 --- a/packages/server/src/mdl_server/app.py +++ b/packages/server/src/mdl_server/app.py @@ -9,7 +9,7 @@ from pathlib import Path -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -19,6 +19,7 @@ from mdl_server import commands from mdl_server.git_api import git_router from mdl_server.glossary_api import glossary_router, subject_area_router +from mdl_server.identity import Identity, IdentityPolicy, resolve_identity from mdl_server.ontology_api import ontology_router from mdl_server.projection import project @@ -72,6 +73,25 @@ def create_app( app = FastAPI(title="Modelith", docs_url="/api/docs", openapi_url="/api/openapi.json") cache: dict = {"fingerprint": None, "repo": None} + # Identity is resolved per request from a trusted-proxy header (enterprise) or the + # model dir's git config (solo), or falls back to anonymous — see identity.py. The + # policy is read from the environment ONCE, here, so serve() and the CLI need no + # signature change. Nothing is trusted unless the operator opted in. + policy = IdentityPolicy.from_env() + if policy.misconfigured_secret(): + import warnings + + warnings.warn( + "MDL_AUTH_PROXY_SECRET_HEADER is set but MDL_AUTH_PROXY_SECRET is empty; " + "trusted-header auth is DISABLED (fail-closed). Set the secret value to " + "enable it.", + RuntimeWarning, + stacklevel=2, + ) + + def _identity(request: Request) -> Identity: + return resolve_identity(request.headers, policy, model_dir) + def _load() -> ModelRepo: try: fp = _dir_fingerprint(model_dir) @@ -83,7 +103,9 @@ def _load() -> ModelRepo: raise HTTPException(status_code=404, detail=str(e)) from e @app.get("/api/model") - def get_model(subject_area: str = "") -> JSONResponse: + def get_model( + subject_area: str = "", ident: Identity = Depends(_identity) + ) -> JSONResponse: """`?subject_area=` scopes the model to that area — which also gives the main canvas a subject-area filter, not just the SME workspace.""" repo = _load() @@ -93,6 +115,10 @@ def get_model(subject_area: str = "") -> JSONResponse: # Which persona this process is serving: staged edits that become a PR, or # direct writes to the working tree. The app reads it to pick its mode. doc["direct"] = direct + # Who the server thinks you are (spec §17). The Studio app reads this: when + # source != "anonymous" it greets you and stops asking for a name. Purely + # informational on a read — identity gates writes, never reads. + doc["identity"] = {"name": ident.name, "email": ident.email, "source": ident.source} doc["domains"] = sorted(d.name for d in repo.model.domains.values()) return JSONResponse(doc) @@ -219,7 +245,7 @@ def command(body: dict) -> JSONResponse: # classify, conflicts, context, proposals) must work under --read-only, which # is exactly the catalog-browse and "SME just looking" case. Writes are gated # inside the router. - app.include_router(git_router(model_dir, read_only=read_only)) + app.include_router(git_router(model_dir, read_only=read_only, identity_policy=policy)) # Static canvas build. Mounted last so /api/* wins. if STATIC_DIR.exists(): diff --git a/packages/server/src/mdl_server/git_api.py b/packages/server/src/mdl_server/git_api.py index 26a6660..8ac7408 100644 --- a/packages/server/src/mdl_server/git_api.py +++ b/packages/server/src/mdl_server/git_api.py @@ -14,10 +14,17 @@ from dataclasses import dataclass from pathlib import Path -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from pydantic import BaseModel +from mdl_server.identity import ( + Identity, + IdentityPolicy, + effective_author, + resolve_identity, +) + def _git(model_dir: Path, *args: str, timeout: int = 30) -> tuple[int, str]: proc = subprocess.run( @@ -40,7 +47,11 @@ class ChangeOp(BaseModel): class ProposeBody(BaseModel): - user: str + # Optional: a proxy- or git-established identity (spec §17) overrides it, and it + # is only used as the author when nothing else is known (the anonymous fallback, + # today's zero-config behaviour). Kept so a solo user with no git config still + # gets attribution. + user: str = "" slug: str = "" title: str body: str = "" @@ -52,6 +63,15 @@ def _slugify(s: str) -> str: return s or "change" +def _branch_user(ident: Identity, client_user: str) -> str: + """The name that seeds the `sme//` branch prefix. A trusted identity wins + over the query-string `user`, so "your proposals" filters by the real user rather + than a typed guess; otherwise fall back to the client string (today's behaviour).""" + if ident.is_trusted: + return ident.email or ident.name + return client_user + + # Mutation ops that write the catalog-owned meaning-fields. When the glossary's # source of truth is Collibra, /propose refuses these — the UI hides them, but the # server is the real boundary (a scripted client can't smuggle a definition edit in). @@ -687,16 +707,30 @@ def _ahead_behind_branch(model_dir: Path, base: str, branch: str) -> tuple[int, return int(ahead), int(behind) -def git_router(model_dir: Path, *, read_only: bool = False) -> APIRouter: +def git_router( + model_dir: Path, + *, + read_only: bool = False, + identity_policy: IdentityPolicy | None = None, +) -> APIRouter: """Git operations for the canvas and the SME app. READS are always registered — including under `mdl glossary --read-only`, which is exactly the "SME just looking" and catalog-browse case. The SME view has to be able to say which branch it is on and whether a proposal would merge cleanly, and a diff is a read. Only the mutating routes (commit, - discard, propose) sit behind `read_only`, matching ontology_router.""" + discard, propose) sit behind `read_only`, matching ontology_router. + + `identity_policy` (spec §17) decides how the acting user is established for the + propose author and the "your proposals" branch prefix. None means the solo + default (git config / anonymous), which is what stand-alone callers and the + existing tests get.""" router = APIRouter(prefix="/api/git") model_dir = Path(model_dir).resolve() + policy = identity_policy if identity_policy is not None else IdentityPolicy.from_env({}) + + def _identity(request: Request) -> Identity: + return resolve_identity(request.headers, policy, model_dir) @router.get("/status") def status() -> JSONResponse: @@ -759,15 +793,17 @@ def conflicts(base: str = "main") -> JSONResponse: return JSONResponse(_conflicts_response(model_dir, base)) @router.get("/context") - def context(user: str = "") -> JSONResponse: + def context(user: str = "", ident: Identity = Depends(_identity)) -> JSONResponse: """Everything the SME view's git banner needs, in one call.""" - return JSONResponse(_context_response(model_dir, user, read_only=read_only)) + return JSONResponse( + _context_response(model_dir, _branch_user(ident, user), read_only=read_only) + ) @router.get("/proposals") - def proposals(user: str = "") -> JSONResponse: + def proposals(user: str = "", ident: Identity = Depends(_identity)) -> JSONResponse: """Open proposal branches with their state. Everything except the PR row is derived from plain git, so the list is never empty without `gh`.""" - return JSONResponse(_proposals_response(model_dir, user)) + return JSONResponse(_proposals_response(model_dir, _branch_user(ident, user))) if not read_only: @@ -792,10 +828,17 @@ def discard() -> JSONResponse: return JSONResponse({"ok": ok, "error": None if ok else f"{out1}\n{out2}"}) @router.post("/propose") - def propose(body: ProposeBody) -> JSONResponse: + def propose( + body: ProposeBody, ident: Identity = Depends(_identity) + ) -> JSONResponse: """The SME PR flow (§5.1), atomic: branch -> apply commands through the shared mutation engine -> commit with Co-authored-by -> push -> open a PR. - Degrades gracefully with no remote / no gh so the SME always gets a result.""" + Degrades gracefully with no remote / no gh so the SME always gets a result. + + The commit author is the SERVER-resolved identity (spec §17): a + proxy-authenticated user or the git config identity OVERRIDES the + self-asserted body.user, so the attribution is trustworthy. Only when + nothing is established (anonymous) do we fall back to body.user.""" # The server is the real boundary, not the UI: reject anything outside # the allow-list BEFORE branching, so a refused proposal leaves no stray # branch behind. @@ -844,8 +887,13 @@ def propose(body: ProposeBody) -> JSONResponse: status_code=409, ) + # The effective author drives BOTH the branch prefix and the commit + # trailer, so the two never disagree. + author = effective_author(ident, body.user) + author_key = author.email or author.name or "you" + slug = _slugify(body.slug or body.title) - branch_name = f"sme/{_slugify(body.user)}/{slug}" + branch_name = f"sme/{_slugify(author_key)}/{slug}" # Remember where we started. Without this the server is left sitting on # the SME branch: the next SME browses an un-merged proposal as if it # were truth, and a second propose branches off the first, stacking @@ -875,8 +923,9 @@ def propose(body: ProposeBody) -> JSONResponse: _git(model_dir, "add", "--", ".") message = body.body.strip() or body.title.strip() - email = f"{_slugify(body.user)}@users.noreply.github.com" - message += f"\n\nCo-authored-by: {body.user} <{email}>" + if author.source != "anonymous" or author.name: + trailer_name = author.name or author_key + message += f"\n\nCo-authored-by: {trailer_name} <{author.email}>" code, out = _git(model_dir, "commit", "-m", message, "--", ".") if code != 0: return JSONResponse({"ok": False, "error": out}, status_code=409) diff --git a/packages/server/src/mdl_server/identity.py b/packages/server/src/mdl_server/identity.py new file mode 100644 index 0000000..64ad605 --- /dev/null +++ b/packages/server/src/mdl_server/identity.py @@ -0,0 +1,215 @@ +"""Who is this request from? — a thin, IdP-agnostic identity seam (spec §17). + +Two personas, one rule each: + + solo Nobody logs in. The acting user is whoever the model dir's git config + says — exactly like a normal `git commit`. Zero configuration, zero + friction. This is the default and needs nothing set up. + + enterprise A reverse proxy (oauth2-proxy, Pomerium, cloudflared, nginx + auth_request, Azure App Proxy, …) authenticates against the org IdP — + OIDC, SAML, whatever — and injects an identity header. Modelith trusts + THAT header and implements no OAuth/OIDC/SAML flow of its own, so every + IdP "just works" without Modelith owning redirect URIs, client secrets, + token validation, or four provider code paths. + +Safe by default: a raw request header is NEVER trusted unless the operator explicitly +named it via MDL_AUTH_TRUSTED_USER_HEADER. An app accidentally exposed to the internet +with no config will not honour a client-supplied X-Forwarded-User — it resolves to the +server's git identity, or to anonymous. Trusted-header auth is only sound when the app +is not directly reachable and only the proxy can reach it (bind to localhost / a +private network; the proxy terminates public traffic). Modelith cannot verify network +topology, so it documents the contract and fails closed. + +Identity is a WRITE-time concern: it decides who a proposal is attributed to. It never +gates a read, and it is not authorization — who may MERGE stays with git (branch +protection + CODEOWNERS). +""" + +from __future__ import annotations + +import hmac +import os +import subprocess +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +# Env var names, namespaced MDL_AUTH_* (see spec §17.2). +ENV_USER_HEADER = "MDL_AUTH_TRUSTED_USER_HEADER" +ENV_NAME_HEADER = "MDL_AUTH_TRUSTED_NAME_HEADER" +ENV_SECRET_HEADER = "MDL_AUTH_PROXY_SECRET_HEADER" +ENV_SECRET_VALUE = "MDL_AUTH_PROXY_SECRET" + + +@dataclass(frozen=True) +class Identity: + """The resolved actor. `source` records HOW we know who this is, so the propose + flow can decide whether to trust it over a self-asserted client string.""" + + name: str # display / Co-authored-by name, e.g. "Anita Hough" + email: str # for the git author/trailer; may be synthesized from the name + source: str # "proxy" | "git" | "anonymous" + + @property + def is_trusted(self) -> bool: + """A proxy- or git-established identity outranks anything the client sends.""" + return self.source in ("proxy", "git") + + +ANONYMOUS = Identity(name="", email="", source="anonymous") + + +@dataclass(frozen=True) +class IdentityPolicy: + """Resolved ONCE at create_app time from the environment, then immutable. The + presence of a trusted user header is the master switch for header auth.""" + + trusted_user_header: str | None + trusted_name_header: str | None + trusted_secret_header: str | None + trusted_secret_value: str | None + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> IdentityPolicy: + env = os.environ if env is None else env + user_header = (env.get(ENV_USER_HEADER) or "").strip() or None + name_header = (env.get(ENV_NAME_HEADER) or "").strip() or None + secret_header = (env.get(ENV_SECRET_HEADER) or "").strip() or None + secret_value = env.get(ENV_SECRET_VALUE) or None + + # Fail closed: a configured secret GATE with no secret VALUE to check against + # would trust the user header on nothing more than "a header named X exists". + # Rather than trust it, disable header auth entirely and let the caller warn. + if secret_header and not secret_value: + user_header = None + name_header = None + secret_header = None + secret_value = None + + return cls( + trusted_user_header=user_header, + trusted_name_header=name_header, + trusted_secret_header=secret_header, + trusted_secret_value=secret_value, + ) + + @property + def header_auth_enabled(self) -> bool: + return bool(self.trusted_user_header) + + def misconfigured_secret(self, env: Mapping[str, str] | None = None) -> bool: + """True when the operator set a secret HEADER but no secret VALUE — the + fail-closed case from_env swallowed. Surfaced only so create_app can log a + one-line startup warning; resolution itself is already safe.""" + env = os.environ if env is None else env + return bool((env.get(ENV_SECRET_HEADER) or "").strip()) and not ( + env.get(ENV_SECRET_VALUE) or None + ) + + +def _name_from_email(email: str) -> str: + """Best-effort display name from an email local-part: "anita.hough@corp.com" + -> "Anita Hough". Falls back to the whole string when there is no local-part.""" + local = email.split("@", 1)[0] if "@" in email else email + parts = [p for p in local.replace("_", ".").replace("-", ".").split(".") if p] + return " ".join(p.capitalize() for p in parts) if parts else email + + +def _from_trusted_header(headers: Mapping[str, str], policy: IdentityPolicy) -> Identity | None: + """The proxy path. Returns None (never raises) unless header auth is on, the + optional shared secret matches, and a non-empty user header is present.""" + if not policy.header_auth_enabled: + return None + + # Prove the request came THROUGH the proxy, not from a client spoofing the user + # header directly. Constant-time compare so a timing side-channel can't probe it. + if policy.trusted_secret_header: + presented = headers.get(policy.trusted_secret_header, "") + if not hmac.compare_digest(presented, policy.trusted_secret_value or ""): + return None + + assert policy.trusted_user_header is not None # header_auth_enabled guarantees it + user = (headers.get(policy.trusted_user_header) or "").strip() + if not user: + return None + + name = "" + if policy.trusted_name_header: + name = (headers.get(policy.trusted_name_header) or "").strip() + email = user if "@" in user else "" + if not name: + name = _name_from_email(user) if email else user + if not email: + # A bare user id with no name header: synthesize a noreply address so the + # commit trailer is still well-formed. + email = f"{user}@users.noreply" + return Identity(name=name, email=email, source="proxy") + + +def _from_git_config(model_dir: Path) -> Identity | None: + """The solo path: whoever this laptop's git config says. None if git config is + empty (or git is absent) — then we degrade to anonymous.""" + + def _cfg(key: str) -> str: + try: + proc = subprocess.run( + ["git", "-C", str(model_dir), "config", key], + capture_output=True, + text=True, + timeout=5, + ) + except (subprocess.SubprocessError, OSError): + return "" + return proc.stdout.strip() if proc.returncode == 0 else "" + + name = _cfg("user.name") + email = _cfg("user.email") + if not name and not email: + return None + if not name: + name = _name_from_email(email) + if not email: + email = "unknown@users.noreply" + return Identity(name=name, email=email, source="git") + + +def resolve_identity( + headers: Mapping[str, str], policy: IdentityPolicy, model_dir: Path +) -> Identity: + """Trusted header (only if opted in) -> git config -> anonymous. + + A missing/blank trusted header when header auth is on is not an error: it means + "the proxy did not authenticate this request", and we fall through to git/anonymous + rather than trusting anything raw.""" + ident = _from_trusted_header(headers, policy) + if ident is not None: + return ident + ident = _from_git_config(model_dir) + if ident is not None: + return ident + return ANONYMOUS + + +def effective_author(ident: Identity, client_user: str) -> Identity: + """Who a proposal is attributed to. + + A trusted identity (proxy or git) WINS over the self-asserted client string — + that is the whole point: the commit author becomes trustworthy. Only when nothing + is established (anonymous) do we fall back to the client-supplied name, preserving + today's zero-config behaviour so nothing regresses.""" + if ident.is_trusted: + return ident + user = (client_user or "").strip() + if not user: + return ANONYMOUS + email = user if "@" in user else f"{_slug(user)}@users.noreply.github.com" + name = user if "@" not in user else _name_from_email(user) + return Identity(name=name, email=email, source="anonymous") + + +def _slug(s: str) -> str: + import re + + s = re.sub(r"[^a-z0-9]+", "-", s.strip().lower()).strip("-") + return s or "change" diff --git a/packages/server/tests/test_diff_api.py b/packages/server/tests/test_diff_api.py index 2a0873d..2f310e6 100644 --- a/packages/server/tests/test_diff_api.py +++ b/packages/server/tests/test_diff_api.py @@ -220,10 +220,12 @@ def test_conflicts_writes_nothing(client, repo): def test_context_reports_branch_and_dirtiness(client, repo): + # The trusted git-config identity (t@t.co) overrides the query-string user, + # so the branch prefix reflects who the server actually thinks you are (§17). doc = client.get("/api/git/context", params={"user": "a.hough"}).json() assert doc["git"] and doc["branch"] == "main" and doc["on_base"] is True assert doc["dirty"] is False and doc["can_propose"] is True - assert doc["sme_branch_prefix"] == "sme/a-hough/" + assert doc["sme_branch_prefix"] == "sme/t-t-co/" (repo / "conceptual" / "entities" / "counterparty.yaml").write_text("broken: true\n") doc = client.get("/api/git/context").json() @@ -236,15 +238,15 @@ def test_proposals_are_fully_populated_without_gh(client, repo, monkeypatch): monkeypatch.setattr(ga.shutil, "which", lambda _n: None) # no gh on PATH - _git(repo, "checkout", "-q", "-b", "sme/a-hough/clarify") + _git(repo, "checkout", "-q", "-b", "sme/t-t-co/clarify") ce = repo / "conceptual" / "entities" / "counterparty.yaml" ce.write_text(ce.read_text().replace("A legal person", "Restated")) _git(repo, "commit", "-qam", "Clarify Counterparty definition") _git(repo, "checkout", "-q", "main") - doc = client.get("/api/git/proposals", params={"user": "a.hough"}).json() + doc = client.get("/api/git/proposals").json() assert doc["ok"] and doc["gh"] is False - p = next(x for x in doc["proposals"] if x["branch"] == "sme/a-hough/clarify") + p = next(x for x in doc["proposals"] if x["branch"] == "sme/t-t-co/clarify") # everything but the PR row survives the absence of gh assert p["title"] == "Clarify Counterparty definition" assert p["pushed"] is False and p["merged"] is False and p["ahead"] == 1 @@ -252,15 +254,15 @@ def test_proposals_are_fully_populated_without_gh(client, repo, monkeypatch): def test_merged_proposal_is_reported_as_merged(client, repo): - _git(repo, "checkout", "-q", "-b", "sme/a-hough/syn") + _git(repo, "checkout", "-q", "-b", "sme/t-t-co/syn") ce = repo / "conceptual" / "entities" / "counterparty.yaml" ce.write_text(ce.read_text() + "\n") _git(repo, "commit", "-qam", "Add synonym") _git(repo, "checkout", "-q", "main") - _git(repo, "merge", "-q", "--no-ff", "-m", "merge", "sme/a-hough/syn") + _git(repo, "merge", "-q", "--no-ff", "-m", "merge", "sme/t-t-co/syn") doc = client.get("/api/git/proposals").json() - p = next(x for x in doc["proposals"] if x["branch"] == "sme/a-hough/syn") + p = next(x for x in doc["proposals"] if x["branch"] == "sme/t-t-co/syn") assert p["merged"] is True diff --git a/packages/server/tests/test_identity.py b/packages/server/tests/test_identity.py new file mode 100644 index 0000000..7efedeb --- /dev/null +++ b/packages/server/tests/test_identity.py @@ -0,0 +1,197 @@ +"""Identity resolution + safe defaults + author precedence (spec §17). + +Pure unit tests — no server. The one rule under test everywhere: a raw request +header is trusted ONLY when the operator opted in, and a trusted identity overrides +any self-asserted client string. +""" + +from __future__ import annotations + +import subprocess + +import pytest +from mdl_server.identity import ( + ENV_NAME_HEADER, + ENV_SECRET_HEADER, + ENV_SECRET_VALUE, + ENV_USER_HEADER, + IdentityPolicy, + _name_from_email, + effective_author, + resolve_identity, +) + + +def _off() -> IdentityPolicy: + """A policy with header auth OFF — the default a solo user runs under.""" + return IdentityPolicy.from_env({}) + + +@pytest.fixture +def git_dir(tmp_path): + """A dir with a local git config identity (the solo path).""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Solo Sam"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.email", "sam@laptop.local"], check=True + ) + return tmp_path + + +@pytest.fixture +def bare_dir(tmp_path, monkeypatch): + """A dir with NO git identity at all — the true anonymous case. We isolate git + from any global/system config on the test machine (a developer's ~/.gitconfig + would otherwise supply a real identity and mask 'anonymous'), which is the + point: on a laptop WITH a global gitconfig the solo path correctly resolves to + that identity — see the git_dir tests — so 'anonymous' can only be exercised by + isolating it.""" + empty = tmp_path / "empty.gitconfig" + empty.write_text("") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(empty)) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(empty)) + d = tmp_path / "work" + d.mkdir() + return d + + +# --- the safe default: a raw header is never trusted without opt-in --------------- + + +def test_raw_header_ignored_when_auth_off(bare_dir): + """The single most important test: header auth off + a spoofed X-Forwarded-User + header => anonymous, NEVER proxy. An app exposed with no config is safe.""" + ident = resolve_identity( + {"X-Forwarded-User": "attacker@evil.com"}, _off(), bare_dir + ) + assert ident.source == "anonymous" + assert ident.name == "" and ident.email == "" + + +def test_git_config_used_when_no_proxy(git_dir): + ident = resolve_identity({"X-Forwarded-User": "ignored@x.com"}, _off(), git_dir) + assert ident.source == "git" + assert ident.name == "Solo Sam" + assert ident.email == "sam@laptop.local" + + +def test_anonymous_when_nothing_known(bare_dir): + assert resolve_identity({}, _off(), bare_dir).source == "anonymous" + + +# --- the proxy path (opted in) ---------------------------------------------------- + + +def test_proxy_header_trusted_when_opted_in(bare_dir): + policy = IdentityPolicy.from_env({ENV_USER_HEADER: "X-Auth-Request-Email"}) + ident = resolve_identity( + {"X-Auth-Request-Email": "anita.hough@corp.com"}, policy, bare_dir + ) + assert ident.source == "proxy" + assert ident.email == "anita.hough@corp.com" + assert ident.name == "Anita Hough" # derived from the local-part + + +def test_proxy_name_header_wins_over_derived(bare_dir): + policy = IdentityPolicy.from_env( + {ENV_USER_HEADER: "X-Auth-Request-Email", ENV_NAME_HEADER: "X-Auth-Request-User"} + ) + ident = resolve_identity( + {"X-Auth-Request-Email": "ah@corp.com", "X-Auth-Request-User": "Dr Anita Hough"}, + policy, + bare_dir, + ) + assert ident.name == "Dr Anita Hough" + + +def test_proxy_falls_through_when_header_blank(git_dir): + """Header auth on but the proxy sent no user header on this request => not an + error, fall through to git config.""" + policy = IdentityPolicy.from_env({ENV_USER_HEADER: "X-Auth-Request-Email"}) + ident = resolve_identity({}, policy, git_dir) + assert ident.source == "git" + + +# --- the shared-secret gate ------------------------------------------------------- + + +def _secret_policy() -> IdentityPolicy: + return IdentityPolicy.from_env( + { + ENV_USER_HEADER: "X-User", + ENV_SECRET_HEADER: "X-Proxy-Secret", + ENV_SECRET_VALUE: "s3cr3t", + } + ) + + +def test_secret_match_trusts_header(bare_dir): + ident = resolve_identity( + {"X-User": "a@b.com", "X-Proxy-Secret": "s3cr3t"}, _secret_policy(), bare_dir + ) + assert ident.source == "proxy" + + +def test_secret_mismatch_does_not_trust(bare_dir): + ident = resolve_identity( + {"X-User": "a@b.com", "X-Proxy-Secret": "wrong"}, _secret_policy(), bare_dir + ) + assert ident.source == "anonymous" + + +def test_secret_missing_does_not_trust(bare_dir): + ident = resolve_identity({"X-User": "a@b.com"}, _secret_policy(), bare_dir) + assert ident.source == "anonymous" + + +def test_secret_header_without_value_fails_closed(bare_dir): + """A secret HEADER configured with no secret VALUE must disable header auth + entirely rather than trust the user header on a header's mere existence.""" + policy = IdentityPolicy.from_env( + {ENV_USER_HEADER: "X-User", ENV_SECRET_HEADER: "X-Proxy-Secret"} + ) + assert policy.header_auth_enabled is False + assert policy.misconfigured_secret( + {ENV_USER_HEADER: "X-User", ENV_SECRET_HEADER: "X-Proxy-Secret"} + ) + ident = resolve_identity({"X-User": "a@b.com"}, policy, bare_dir) + assert ident.source == "anonymous" + + +# --- author precedence ------------------------------------------------------------ + + +def test_effective_author_proxy_overrides_client(bare_dir): + policy = IdentityPolicy.from_env({ENV_USER_HEADER: "X-User"}) + ident = resolve_identity({"X-User": "real@corp.com"}, policy, bare_dir) + author = effective_author(ident, "totally.someone.else") + assert author.source == "proxy" + assert author.email == "real@corp.com" # the spoofed client string is ignored + + +def test_effective_author_git_overrides_client(git_dir): + ident = resolve_identity({}, _off(), git_dir) + author = effective_author(ident, "someone.else") + assert author.source == "git" + assert author.email == "sam@laptop.local" + + +def test_effective_author_falls_back_to_client_when_anonymous(bare_dir): + ident = resolve_identity({}, _off(), bare_dir) + author = effective_author(ident, "a.hough") + assert author.source == "anonymous" + assert author.name == "a.hough" + assert author.email == "a-hough@users.noreply.github.com" + + +def test_effective_author_anonymous_and_empty_stays_anonymous(bare_dir): + ident = resolve_identity({}, _off(), bare_dir) + author = effective_author(ident, "") + assert author.source == "anonymous" + assert author.name == "" + + +def test_name_from_email(): + assert _name_from_email("anita.hough@corp.com") == "Anita Hough" + assert _name_from_email("a_b-c@x.io") == "A B C" + assert _name_from_email("plainuser") == "Plainuser" diff --git a/packages/server/tests/test_propose.py b/packages/server/tests/test_propose.py index 790d181..9f9965c 100644 --- a/packages/server/tests/test_propose.py +++ b/packages/server/tests/test_propose.py @@ -59,7 +59,9 @@ def test_propose_creates_branch_and_coauthored_commit(client, git_model_dir): assert resp.status_code == 200, resp.text data = resp.json() assert data["ok"] and data["applied"] == 1 - assert data["branch"] == "sme/a-hough/clarify-counterparty" + # the git-config identity (t@t.co) now authors the proposal, overriding the + # self-asserted "a.hough" — this is the §17 identity behaviour. + assert data["branch"] == "sme/t-t-co/clarify-counterparty" # no origin remote -> graceful message, not an error assert data["pushed"] is False assert "no `origin` remote" in data["message"] @@ -69,15 +71,15 @@ def test_propose_creates_branch_and_coauthored_commit(client, git_model_dir): # un-merged proposal as truth, and a second propose stacked onto the first. assert data["returned_to"] == "main" assert _git(git_model_dir, "rev-parse", "--abbrev-ref", "HEAD") == "main" - assert "sme/a-hough/clarify-counterparty" in _git(git_model_dir, "branch", "--list", "sme/*") + assert "sme/t-t-co/clarify-counterparty" in _git(git_model_dir, "branch", "--list", "sme/*") # the commit lives on the branch, so read the log from there - log = _git(git_model_dir, "log", "-1", "--pretty=%B", "sme/a-hough/clarify-counterparty") - assert "Co-authored-by: a.hough" in log + log = _git(git_model_dir, "log", "-1", "--pretty=%B", "sme/t-t-co/clarify-counterparty") + assert "Co-authored-by: t " in log assert "prospective parties" in log # the edit actually landed from mdl_core.repo import ModelRepo - _git(git_model_dir, "checkout", "sme/a-hough/clarify-counterparty") + _git(git_model_dir, "checkout", "sme/t-t-co/clarify-counterparty") repo = ModelRepo.load(git_model_dir) ce = repo.model.conceptual_entities[cpty["id"]] assert "may have" in (ce.definition or "") @@ -347,3 +349,88 @@ def test_the_hosts_own_link_is_still_preferred_when_present(): ) # a re-push prints no such line — this is the case that made scraping unsafe assert _pr_url_from_push("To github.com:acme/repo.git\n abc..def f -> f\n") is None + + +# --- identity: the server-resolved author overrides a self-asserted string (§17) --- + + +def test_propose_trusts_proxy_identity_over_client_user(git_model_dir, monkeypatch): + """A proxy-authenticated user proposing with a DIFFERENT self-asserted body.user + must produce a commit attributed to the proxy identity, not the spoofed string.""" + from fastapi.testclient import TestClient + from mdl_server import create_app + from mdl_server.identity import ENV_USER_HEADER + + monkeypatch.setenv(ENV_USER_HEADER, "X-Auth-Request-Email") + client = TestClient(create_app(git_model_dir)) + + doc = client.get("/api/glossary/terms").json() + cpty = next(t for t in doc["terms"] if t["name"] == "Counterparty") + + resp = client.post( + "/api/git/propose", + headers={"X-Auth-Request-Email": "real.user@corp.com"}, + json={ + "user": "impostor", # self-asserted; must be ignored + "slug": "clarify", + "title": "Clarify", + "changes": [ + {"op": "set_definition", "payload": {"id": cpty["id"], "definition": "x."}} + ], + }, + ) + assert resp.status_code == 200, resp.text + branch = resp.json()["branch"] + # branch prefix derives from the proxy identity, not "impostor" + assert branch.startswith("sme/real-user-corp-com/") + assert "impostor" not in branch + + body = _git(git_model_dir, "log", branch, "-1", "--format=%b") + assert "real.user@corp.com" in body + assert "impostor" not in body + + +def test_api_model_reports_proxy_identity(git_model_dir, monkeypatch): + from fastapi.testclient import TestClient + from mdl_server import create_app + from mdl_server.identity import ENV_USER_HEADER + + monkeypatch.setenv(ENV_USER_HEADER, "X-Auth-Request-Email") + client = TestClient(create_app(git_model_dir)) + ident = client.get( + "/api/model", headers={"X-Auth-Request-Email": "anita.hough@corp.com"} + ).json()["identity"] + assert ident["source"] == "proxy" + assert ident["name"] == "Anita Hough" + + +def test_api_model_reports_git_identity_solo(client): + """No proxy configured: /api/model greets the git-config user (the fixture sets + user.name=t / user.email=t@t.co).""" + ident = client.get("/api/model").json()["identity"] + assert ident["source"] == "git" + assert ident["email"] == "t@t.co" + + +def test_propose_solo_still_attributes_without_proxy(client, git_model_dir): + """Regression: with no proxy policy, propose attributes to the git-config identity + and the flow still succeeds.""" + doc = client.get("/api/glossary/terms").json() + cpty = next(t for t in doc["terms"] if t["name"] == "Counterparty") + resp = client.post( + "/api/git/propose", + json={ + "user": "a.hough", + "slug": "solo-change", + "title": "Solo change", + "changes": [ + {"op": "set_definition", "payload": {"id": cpty["id"], "definition": "y."}} + ], + }, + ) + assert resp.status_code == 200, resp.text + branch = resp.json()["branch"] + # git config identity (t@t.co) wins over the self-asserted "a.hough" + assert branch.startswith("sme/t-t-co/") + body = _git(git_model_dir, "log", branch, "-1", "--format=%b") + assert "t@t.co" in body From 79a5a083ea3c818eb7341936057d6182f9c55f77 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Thu, 10 Sep 2026 00:53:19 +1000 Subject: [PATCH 2/6] Studio: greet an authenticated user instead of asking for a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the /api/model identity block (spec §17): when the server established a trusted identity (proxy or git config), the propose dialog shows "Signed in as X — this proposal is attributed to you" instead of the free-text "Your name" input, and no longer requires or sends a self-asserted name (the server overrides it anyway). Falls back to the name input only for an anonymous deployment, so nothing changes for a solo user with no git identity. Rebuilds the static SME bundle. --- canvas/src/sme/ProposeDialog.tsx | 47 +++++++++++++++---- canvas/src/sme/SmeApp.tsx | 11 +++++ canvas/src/sme/sme.css | 4 ++ canvas/src/types.ts | 4 ++ .../{mocks-Dfvl6wOL.js => mocks-CG2pmWEb.js} | 0 .../mdl_server/static/assets/sme-BFiNU1gH.css | 1 + .../mdl_server/static/assets/sme-C7XDneCF.css | 1 - .../mdl_server/static/assets/sme-CpbO-6IJ.js | 2 + .../mdl_server/static/assets/sme-M2gtckV0.js | 2 - .../server/src/mdl_server/static/mocks.html | 4 +- .../server/src/mdl_server/static/sme.html | 4 +- 11 files changed, 63 insertions(+), 17 deletions(-) rename packages/server/src/mdl_server/static/assets/{mocks-Dfvl6wOL.js => mocks-CG2pmWEb.js} (100%) create mode 100644 packages/server/src/mdl_server/static/assets/sme-BFiNU1gH.css delete mode 100644 packages/server/src/mdl_server/static/assets/sme-C7XDneCF.css create mode 100644 packages/server/src/mdl_server/static/assets/sme-CpbO-6IJ.js delete mode 100644 packages/server/src/mdl_server/static/assets/sme-M2gtckV0.js diff --git a/canvas/src/sme/ProposeDialog.tsx b/canvas/src/sme/ProposeDialog.tsx index f6276f0..ab91459 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,12 +20,17 @@ 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", @@ -35,15 +41,20 @@ export function ProposeDialog({ 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(), + user: authenticated ? "" : user.trim(), slug: title, title, body: body.trim(), @@ -113,10 +124,22 @@ export function ProposeDialog({ ))} - + {authenticated ? ( +

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

+ ) : ( + + )}