Repository navigation
feat(identity): delegated surface identity (SurfaceIdentityLink) for surface-relayed actions #350
Description
Parent roadmap: mctlhq/.github#21 (surfaces), node surface-identity-link.
Related: mctlhq/.github#91 (Unified Identity epic). This is deliberately not that epic. It is the smallest mechanism several surfaces are blocked on today, and it should stay compatible with that epic's future principal-model.
Problem
Surfaces reach mctl-api as a service principal (the Telegram bot, the portal backend with its static MCTL_API_TOKEN). Several platform operations must be attributed to the human behind the surface:
- mctl-api#261 accepts a human-input answer only from a GitHub-verified human credential (
github:<login>inactor_refs), and refuses the service principal with 403. This blocks mctl-telegram#571 and the answer action of mctl-portal#124. workitem/v1(mctl-api#349) forbids a caller-supplied actor. A Telegram user resolves to a principal only through an explicitSurfaceIdentityLink(docs/work-context-contract.md§ Identity and authorization). This blocks mctl-telegram#443.
Today there is no correct way to do this. Each of these shortcuts is excluded:
- passing an arbitrary GitHub login from the surface;
- letting the service principal answer "on behalf of" a user;
- forwarding a browser's GitHub token through services.
Mechanism (minimum)
The authenticated surface or service principal authenticates the transport. A verified, previously established link between a human principal and a surface-native identity (a Telegram user id, a portal session subject) supplies the subject. mctl-api accepts that subject as the canonical actor, and only for surface principals allowed to relay for that surface.
- Link creation is an authenticated call by the human principal themself (e.g.
POST /api/v1/surface-identities). A surface can never create a link on its own authority. This is the contract'sSurfaceIdentityLinkresource, extracted so that human input and the portal can use it without depending on WorkItems. - Relayed requests carry the surface-native id. mctl-api resolves it through the link and records both the acting surface principal and the subject in audit (the act-as / on-behalf-of distinction).
- Revocation and expiry are supported; a revoked or unknown link fails closed.
- Scope: the relay is accepted only on routes that opt in (initially the human-input response and the
workitem/v1surface routes). It is never a general impersonation grant.
Acceptance criteria
- A link can be created only by the human principal it names; a surface or service principal cannot create or alter links. Tested.
- A relayed request with a valid link is attributed to the linked human. Audit records both the surface principal and the subject.
- A relayed request with no link, or a revoked, expired or foreign link, fails closed (403 with a typed reason). Tested.
- A caller-asserted subject (a login or actor field in the body or a header) is never accepted without a link. Tested.
- The human-input response endpoint (mctl-api#261) accepts a linked relayed answer as
github:<login>of the linked human, and still refuses the bare service principal. - The mechanism is documented in mctl-docs next to the human-input surface-adapter guide.
Out of scope
- The canonical principal model, federation, and the GitHub decoupling work in Epic: MCTL Unified Identity on ZITADEL and Agent Identity .github#91.
- Surface-side UX for linking (per-surface follow-ups).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
mashkovd commentedon Sep 23, 2026
Blocker: two design gaps before this can be built fail-closed (checked 2026-09-23 on
main)Facts
MCTL_AGENT_SERVICE_TOKEN→auth.ServiceUserID = "mctl-agent", groups["admins"](internal/auth/oidc.go). There is no per-surface principal. The Telegram bot, the portal backend and mctl-agents are indistinguishable once authenticated.github:alicecan bindtg:<bob's id>, and Bob's later relayed answers are then recorded as Alice's (attribution spoofing). The link would be "verified" on one side only.Proposed default (not implemented; needs the owner's yes)
surface:telegram,surface:portal), non-admin, each allowed to relay only its ownsurface.mctl-agentkeeps its current role and cannot relay.POST /api/v1/surface-identities/challenges {surface}and gets a short-lived one-time code. They send it to the surface; for Telegram that is the bot. The surface principal redeems{code, external_id}. mctl-api bindsexternal_id↔ human only when the code, the surface and the TTL all match. Neither side alone can create a link.The one question: do we introduce per-surface service tokens (1) and the challenge-code possession proof (2) as the link-creation contract? Or is there another accepted way for mctl-api to know a request really comes from the Telegram surface and that the Telegram id belongs to the linking human?
Implementation stays on hold until this is answered. Nothing is built on the shared
mctl-agenttoken. That would be exactly the "service principal answers on behalf of a user" shortcut the issue excludes.fix(surface-identity): per-end-user rate limits, strict body, locked …
fix(surface-identity): vet the actor before it keys a limiter; cap ea…
feat(surface-identity): relay a surface principal to the linked human (…
Merge pull request #358 from mctlhq/feat/350-surface-identity
feat(surface-identity): relay a surface principal to the linked human (…
fix(surface-identity): #358 review follow-ups (#350)
fix(surface-identity): relay grants no tenant on a failed lookup; aud…
fix(surface-identity): bound the gate's link lookup by the request ti…
Merge pull request #359 from mctlhq/feat/350-relay
mashkovd commentedon Sep 23, 2026
Closed by #359 (merge
56d73f2), on top of #358 (merge3eb2eaa). Merged, not released or deployed. Nothing reachesapi.mctl.aiuntil the next mctl-api release ships andMCTL_SURFACE_TELEGRAM_TOKEN/MCTL_SURFACE_PORTAL_TOKENare configured.Acceptance criteria, with evidence:
TestSurfaceIdentity_OnlyAGitHubHumanCreatesAChallenge,_OnlyTheRightSurfaceRedeemsOnce,_ListAndRevoke(mctl-agentgets 403 on revoke and on listing others),_ReviewFollowUps(an unverified admin cannot enumerate). Neither side alone can link: the human gets a challenge (10 min, single use, bound to the human and the surface, hash-only), and only that surface's own principal can redeem it. Reuse, expiry and the wrong surface are all refused, and the wrong surface burns the code.TestSurfaceRelay_WorkItemsAttributeToTheHuman: ownergithub:alice; the work-item event row and the audit row carry actorgithub:aliceandacting_principal: surface:telegram.TestSurfaceRelay_FailsClosedcovers no header (relay_required), unknown or forged id and another surface's link (link_not_found), revoked (link_revoked) and expired (link_expired). Every 403 is audited assurface_identity.relay_refused.actor_not_accepted; the header frommctl-agentor a human gets 400. A relayed subject is never admin (TestRelayedUserIsNeverAdminAndNeedsASurface) and gets no tenant when the tenant lookup fails.TestSurfaceRelay_HumanInputAnswerIsTheHumansCarriedByTheSurface: a linked relayed answer is accepted asgithub:alicewith signal surfacetelegram. The bare surface principal cannot answer, andmctl-agentis still refused (TestRespondHumanInput_OnlyEligibleHumansMayAnswer).docs/work-context-contract.md§ Surface relay and the OpenAPI tag.Every regression test was mutation-checked: each guard was reverted one at a time and a test failed each time (PR1: 20 + 9; PR2: 16 + 1 + 5 + 4). Reviews: Claude approved both PRs with no open P1/P2; agy PASS on both final heads. P3 follow-ups: #360.
Scope kept as decided: only per-surface principals;
mctl-agentgained no relay authority; relay is opt-in per route; this is not Unified Identity mctlhq/.github#91. Surface-side linking UX is left to the per-surface follow-ups (mctl-telegram#571 / #443, mctl-portal#124).mashkovd commentedon Sep 23, 2026
The last acceptance criterion is done: the mctl-docs page landed in mctlhq/mctl-docs#122 (merge
eb39fe8),docs/human-input/surface-identity.md, linked from the surface adapter guide.