feat: add a workload kind to urn.SessionSubject - #5959
Conversation
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
🦋 Changeset detectedLatest commit: 1dfe29a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
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. |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
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
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
AIM-152
Summary
Adds a fourth kind to
urn.SessionSubjectso a machine vouched for by an external issuer can be thesubof a Gram-issued session JWT. The format isworkload:<remote_session_issuer_id>:<external_subject>, withNewWorkloadSubjectto build one andWorkload()to split it back into its two halves.splitWorkloadIDcuts 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.MaxWorkloadExternalSubjectLengthis 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
AuthContextconstruction without a deliberate decision about what it gets:contextForSessionSubjectnow refuses a workload rather than falling through.AuthContextnames a user or an api key and nothing a workload fits, so handing back a context with an empty identity would read toauthz.Engineas 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.subjectTypealready reportsworkload, so the caller is named by kind.Motivation
A
subis 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:prodis 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_issuersrow 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
workloadkind tourn.SessionSubject(AIM-152, proposal C.1) so a machine vouched for by an external issuer can be thesubof a Gram-issued session JWT. The set was previously closed atuser,apikey, andanonymous, and a workload is none of those. The new format isworkload:<remote_session_issuer_id>:<external_subject>, built withNewWorkloadSubjectand split back withWorkload().The issuer reference is load-bearing: a
subis 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 likerepo:owner/name:ref:refs/heads/mainpass 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.MaxWorkloadExternalSubjectLengthis exported so admission can enforce the budget where the error reaches someone who can act on it.Adding the kind broke two exhaustive switches.
contextForSessionSubjectnow refuses a workload rather than falling through: an empty identity would read toauthz.Engineas 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 describesubject_typeandsubject_urnas able to carryworkload.Written for commit 1dfe29a. Summary will update on new commits.