Skip to content

feat: add a workload kind to urn.SessionSubject - #5959

Open
aa-wong wants to merge 4 commits into
mainfrom
waaronwong/aim-152-feat-add-a-workload-kind-to-urnsessionsubject
Open

feat: add a workload kind to urn.SessionSubject#5959
aa-wong wants to merge 4 commits into
mainfrom
waaronwong/aim-152-feat-add-a-workload-kind-to-urnsessionsubject

Conversation

@aa-wong

@aa-wong aa-wong commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

AIM-152

Summary

Adds a fourth kind to urn.SessionSubject so a machine vouched for by an external issuer can be the sub of a Gram-issued session JWT. The format is workload:<remote_session_issuer_id>:<external_subject>, with NewWorkloadSubject to build one and Workload() to split it back into its two halves.

splitWorkloadID cuts on the first delimiter only. The subjects these platforms mint are themselves colon-heavy — repo:owner/name:ref:refs/heads/main, system:serviceaccount:ns:name, spiffe://… — so everything after the issuer reference is the subject verbatim. Validation is structural rather than cosmetic: an id that does not split into a uuid and a non-empty subject is rejected, so the kind carries a real guarantee that an issuer is present rather than being an opaque string with a prefix.

MaxWorkloadExternalSubjectLength is exported deliberately. The id segment is capped at 128 bytes and an over-long id is rejected, not truncated, so the budget is a real limit — and the useful place to enforce it is where an operator admits a workload identity, not where a session is minted. A subject too long to fit is a configuration problem; catching it at admission puts the error in front of the person who can shorten it, where catching it at token exchange produces a workload that authenticates correctly and then cannot hold a session.

Adding the kind breaks two exhaustive switches, which is the intended forcing function — a new subject kind must not be able to reach AuthContext construction without a deliberate decision about what it gets:

  • contextForSessionSubject now refuses a workload rather than falling through. AuthContext names a user or an api key and nothing a workload fits, so handing back a context with an empty identity would read to authz.Engine as an authenticated session belonging to nobody. Giving it the anonymous treatment would be worse still: anonymous callers deliberately get no permission context, which for an admitted, issuer-vouched machine means skipping authorization entirely. The real answer — a third identity field — is the next ticket's decision, so this fails closed instead of guessing it.
  • The user-sessions view resolves no display name for a workload, since the row does not join the issuer. subjectType already reports workload, so the caller is named by kind.

Motivation

A sub is unique within the issuer that minted it and never across issuers. Two workloads vouched for by two different issuers can present a byte-identical subject — repo:acme/api:environment:prod is ordinary enough to collide by accident, and an organization that runs its own issuer controls every claim in it. If the Gram-side identity carried only that value, one machine's session, grants, and audit trail would be attributed to another.

The issuer is referenced by its remote_session_issuers row id rather than its URL, for three reasons. The URL does not identify a row: that index is deliberately non-unique, because a project, an organization, and a platform issuer may all legitimately point at the same authorization server — so a URL-keyed subject would collapse distinct trust decisions onto one principal, while the admission table keys on the row id. The URL is also editable in place while the row id is not, so a URL-keyed subject would change under existing grants with no cascade to explain it. And a uuid is a fixed 36 bytes where a hostname is unbounded, which matters against a hard 128-byte cap.


Summary by cubic

Adds a fourth workload kind to urn.SessionSubject (AIM-152, proposal C.1) so a machine vouched for by an external issuer can be the sub of a Gram-issued session JWT. The set was previously closed at user, apikey, and anonymous, and a workload is none of those. The new format is workload:<remote_session_issuer_id>:<external_subject>, built with NewWorkloadSubject and split back with Workload().

The issuer reference is load-bearing: a sub is unique within its issuer but not across issuers, so an identity carrying only the external subject would let two workloads from different issuers collide. The issuer is referenced by row id rather than URL because the URL is deliberately non-unique across the tri-tier catalog, is editable in place, and is unbounded in length against the 128-byte cap. The id splits on the first delimiter only, so colon-heavy platform subjects like repo:owner/name:ref:refs/heads/main pass through verbatim. Validation is structural — an id that doesn't split into a uuid and a non-empty subject is rejected, the nil uuid is rejected by name, and over-long ids are rejected, not truncated. MaxWorkloadExternalSubjectLength is exported so admission can enforce the budget where the error reaches someone who can act on it.

Adding the kind broke two exhaustive switches. contextForSessionSubject now refuses a workload rather than falling through: an empty identity would read to authz.Engine as an authenticated session belonging to nobody, and the anonymous treatment would skip authorization entirely for an admitted, issuer-vouched machine. The user-sessions view resolves no display name for a workload since the row doesn't join the issuer. The user-sessions API docs, generated Go types, OpenAPI specs, and dashboard SDK now describe subject_type and subject_urn as able to carry workload.

Written for commit 1dfe29a. Summary will update on new commits.

Review in cubic

Gram's session identities are a closed set of three — a person, an API key, or
an anonymous caller — and a machine vouched for by an external issuer is none
of them. It is not a person, it holds no Gram-issued key, and it is emphatically
not anonymous: an issuer named it.

Add the fourth kind as `workload:<remote_session_issuer_id>:<external_subject>`.
Both halves are load-bearing, because a `sub` is unique within the issuer that
minted it and never across issuers: an identity carrying only the subject would
let two workloads vouched for by different issuers collide, with one machine's
session, grants, and audit trail attributed to another. The issuer is referenced
by row id rather than URL — that URL is deliberately non-unique across the
tri-tier catalog, so it does not identify the trust decision that admitted the
request, and it is unbounded in length where a uuid is not.

Adding the kind breaks two exhaustive switches, which is the point. The
AuthContext path now refuses a workload rather than falling through: handing
back a context with an empty identity would read as an authenticated session
belonging to nobody, and giving it the anonymous treatment would skip
authorization entirely for a caller an issuer vouched for.

Claude-Session: https://claude.ai/code/session_01DRsSfRrF9g7aLbvUcmEwXZ
@aa-wong
aa-wong requested a review from a team as a code owner September 1, 2026 19:51
@aa-wong aa-wong added the enhancement New feature or request label Sep 1, 2026
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

AIM-152

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1dfe29a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Running ultrareview automatically — This adds a new authenticated JWT subject kind with issuer binding, parsing and length guarantees, and changes authorization-context handling, so a subtle identity or validation bug could misattribute sessions or bypass or deny access.. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultrareview completed in 9m 11s

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/internal/platformtools/usersessions/tools.go">

<violation number="1" location="server/internal/platformtools/usersessions/tools.go:60">
P2: When this tool lists a workload session, it returns `SubjectType` as `workload`, but the usersessions design and generated API schemas still document only three kinds. Update the design and regenerate clients/OpenAPI so consumers receive the new kind in the published contract.</violation>
</file>

Linked issue analysis

Linked issue: AIM-152: feat: add a workload kind to urn.SessionSubject

Status Acceptance criteria Notes
Add the workload kind, registration, and a NewWorkloadSubject constructor using the issuer ID and external subject The workload constant and kind map entry are added, and NewWorkloadSubject constructs workload:: values.
Support workload subject parsing, formatting, and extracting the issuer and external subject Parsing and formatting are covered, splitWorkloadID preserves colon-heavy external subjects, and Workload() returns both components.
Reject malformed workload subjects while enforcing the workload ID budget Malformed issuer IDs, missing delimiters, empty subjects, and over-limit external subjects are rejected during validation/parsing.
Round-trip workload subjects through JSON and the database valuer/scanner paths Tests verify JSON marshal/unmarshal and driver Value/Scan round-trips preserve the workload subject.
Ensure exhaustive SessionSubjectKind switches deliberately handle workload subjects The authentication context path rejects workloads explicitly, and the user-sessions view handles the workload display-name case without falling through.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

subjectName = conv.FromPGText[string](row.ApiKeyName)
case urn.SessionSubjectKindAnonymous:
// anonymous subjects have no resolved display name
case urn.SessionSubjectKindWorkload:

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When this tool lists a workload session, it returns SubjectType as workload, but the usersessions design and generated API schemas still document only three kinds. Update the design and regenerate clients/OpenAPI so consumers receive the new kind in the published contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/internal/platformtools/usersessions/tools.go, line 60:

<comment>When this tool lists a workload session, it returns `SubjectType` as `workload`, but the usersessions design and generated API schemas still document only three kinds. Update the design and regenerate clients/OpenAPI so consumers receive the new kind in the published contract.</comment>

<file context>
@@ -57,6 +57,11 @@ func buildView(row repo.ListUserSessionsByProjectIDRow) *types.UserSession {
 		subjectName = conv.FromPGText[string](row.ApiKeyName)
 	case urn.SessionSubjectKindAnonymous:
 		// anonymous subjects have no resolved display name
+	case urn.SessionSubjectKindWorkload:
+		// A workload's name is its issuer and external subject, which this
+		// row does not join, so there is nothing to resolve here. subjectType
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half right, and fixed in e729c04 for the half that was.

The generated schema is not actually constrained. subject_type is declared Attribute("subject_type", String, …) in server/design/usersessions/design.go:183 with no Enum, and lands as a plain SubjectType string in gen/types/user_session.go. So nothing was rejecting or mis-validating workload — the API would have returned it fine.

What was wrong is the description, which read "Subject kind: 'user', 'apikey', or 'anonymous'." That is documentation asserting something false, and it propagates into openapi3.yaml. Updated to include workload and regenerated, so the OpenAPI document and the generated types now carry the corrected description.

I have deliberately not added an Enum constraint. That would be a behavioural change to a shipped API surface — it starts rejecting values that are currently accepted — and the right moment to weigh it is when a workload session can actually appear in this list, which is the display-name work in the follow-up ticket, rather than as a side effect of adding the kind.

Comment thread server/internal/urn/session_subject.go
The nil uuid parses like any other, so a workload subject built from an
uninitialised issuer reference validated cleanly and named no issuer. Every
remote_session_issuers row is minted by generate_uuidv7 and none is ever the
nil uuid, so accepting it let the degenerate case wear the shape of a real
identity — the collision the kind carries an issuer to prevent.

Also correct the subject_type description in the user sessions design, which
still enumerated three kinds, and regenerate. The attribute is a free-form
string with no enum, so nothing was rejecting "workload"; the documentation was
simply wrong about what the field can hold.

Claude-Session: https://claude.ai/code/session_01DRsSfRrF9g7aLbvUcmEwXZ
The subject_urn description enumerated the three URN shapes a session could
carry and the subject_type description the three kinds, both of which stopped
being true when the workload kind landed. Neither attribute is constrained by
an Enum, so nothing was rejecting the new value — the documentation was simply
asserting something false, and it propagates to the OpenAPI document and the
dashboard SDK.

Regenerate the Goa server types and the SDK so the spec, the generated types,
and the client all describe what the field can actually hold.

Claude-Session: https://claude.ai/code/session_01DRsSfRrF9g7aLbvUcmEwXZ
@aa-wong
aa-wong requested a review from a team as a code owner September 1, 2026 20:36
Every other urn type parses its segments with strings.SplitN against the
shared delimiter. Matching that keeps one parsing idiom across the package
rather than two that do the same thing.

The limit stays 2 deliberately: unlike ParseAsset, which rejects a trailing
delimiter, an external subject is colon-heavy by nature
(repo:owner/name:ref:refs/heads/main), so everything after the issuer
reference belongs to it verbatim.

Claude-Session: https://claude.ai/code/session_01DRsSfRrF9g7aLbvUcmEwXZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant