Skip to content

feat(identity): delegated surface identity (SurfaceIdentityLink) for surface-relayed actions #350

Closed

Description

@mashkovd

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> in actor_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 explicit SurfaceIdentityLink (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's SurfaceIdentityLink resource, 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/v1 surface 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

Activity

  1. mashkovd commented on Sep 23, 2026

    @mashkovd
    MemberAuthor

    Blocker: two design gaps before this can be built fail-closed (checked 2026-09-23 on main)

    Facts

    • mctl-api has exactly one non-human machine principal: 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.
    • The acceptance criterion "accepted only for surface principals allowed to relay for that surface" therefore has nothing to check against. Any holder of the one token could relay as any surface, and that token is also admin.
    • "Link creation is an authenticated call by the human principal themself" proves who the human is, but not that the human controls the surface-native id they name. Without a possession proof, github:alice can bind tg:<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)

    1. Per-surface service principals. Separate tokens (surface:telegram, surface:portal), non-admin, each allowed to relay only its own surface. mctl-agent keeps its current role and cannot relay.
    2. Possession proof by challenge. The authenticated human calls 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 binds external_id ↔ human only when the code, the surface and the TTL all match. Neither side alone can create a link.
    3. Relay, revocation, audit of both identities and fail-closed behaviour stay as the issue describes.

    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-agent token. That would be exactly the "service principal answers on behalf of a user" shortcut the issue excludes.

  2. added a commit that references this issue on Sep 23, 2026
  3. mashkovd commented on Sep 23, 2026

    @mashkovd
    MemberAuthor

    Closed by #359 (merge 56d73f2), on top of #358 (merge 3eb2eaa). Merged, not released or deployed. Nothing reaches api.mctl.ai until the next mctl-api release ships and MCTL_SURFACE_TELEGRAM_TOKEN / MCTL_SURFACE_PORTAL_TOKEN are configured.

    Acceptance criteria, with evidence:

    • Link created only by the human it names. A surface or service principal cannot create or alter links: TestSurfaceIdentity_OnlyAGitHubHumanCreatesAChallenge, _OnlyTheRightSurfaceRedeemsOnce, _ListAndRevoke (mctl-agent gets 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.
    • Relayed request attributed to the linked human, audit keeps both identities. TestSurfaceRelay_WorkItemsAttributeToTheHuman: owner github:alice; the work-item event row and the audit row carry actor github:alice and acting_principal: surface:telegram.
    • Fails closed with a typed 403. TestSurfaceRelay_FailsClosed covers 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 as surface_identity.relay_refused.
    • Caller-asserted subject never accepted. Body fields are refused with actor_not_accepted; the header from mctl-agent or a human gets 400. A relayed subject is never admin (TestRelayedUserIsNeverAdminAndNeedsASurface) and gets no tenant when the tenant lookup fails.
    • Human-input response. TestSurfaceRelay_HumanInputAnswerIsTheHumansCarriedByTheSurface: a linked relayed answer is accepted as github:alice with signal surface telegram. The bare surface principal cannot answer, and mctl-agent is still refused (TestRespondHumanInput_OnlyEligibleHumansMayAnswer).
    • Documented in mctl-docs: docs(human-input): surface identity links and relay (mctl-api#350) mctl-docs#122 (in review). Also in 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-agent gained 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).

  4. mashkovd commented on Sep 23, 2026

    @mashkovd
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions