Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions domain/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,19 @@ func GetIdentitySchema() *IdentitySchema {
}
}

// BuildWIMSEURI constructs the WIMSE URI for an identity.
// Format: spiffe://{domain}/{account_id}/{project_id}/{identity_type}/{external_id}
func BuildWIMSEURI(wimseDomain, accountID, projectID string, identityType IdentityType, externalID string) string {
return fmt.Sprintf("spiffe://%s/%s/%s/%s/%s", wimseDomain, accountID, projectID, identityType, externalID)
// MaxSPIFFEIDBytes is the SPIFFE §2.4 hard cap. Spec says MUST NOT exceed.
const MaxSPIFFEIDBytes = 2048

// BuildWIMSEURI constructs the WIMSE URI for an identity:
// spiffe://{domain}/{account_id}/{project_id}/{identity_type}/{external_id}.
// Returns an error if the result exceeds MaxSPIFFEIDBytes — once persisted,
// every downstream system inherits a non-conformant subject claim.
func BuildWIMSEURI(wimseDomain, accountID, projectID string, identityType IdentityType, externalID string) (string, error) {
uri := fmt.Sprintf("spiffe://%s/%s/%s/%s/%s", wimseDomain, accountID, projectID, identityType, externalID)
if n := len(uri); n > MaxSPIFFEIDBytes {
return "", fmt.Errorf("SPIFFE ID exceeds %d bytes (got %d): %s…", MaxSPIFFEIDBytes, n, uri[:64])
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.

medium

Slicing a string by bytes (uri[:64]) can be unsafe if the truncation happens in the middle of a multi-byte UTF-8 character. While SPIFFE IDs are expected to be ASCII-based URIs, using the %.64q format specifier is more robust as it handles rune boundaries correctly, escapes special characters, and provides a clear, quoted representation in the error message.

Suggested change
return "", fmt.Errorf("SPIFFE ID exceeds %d bytes (got %d): %s…", MaxSPIFFEIDBytes, n, uri[:64])
return "", fmt.Errorf("SPIFFE ID exceeds %d bytes (got %d): %.64q...", MaxSPIFFEIDBytes, n, uri)

}
return uri, nil
}

// ValidateSPIFFEPathSegment rejects values that wouldn't survive a round-trip
Expand Down
54 changes: 54 additions & 0 deletions domain/identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package domain

import (
"strings"
"testing"
)

// TestBuildWIMSEURIRejectsOver2048Bytes locks in SPIFFE §2.4. Today's
// varchar(255) schema makes this unreachable through the API surface — the
// test exists so a future schema bump can't silently mint over-cap IDs.
func TestBuildWIMSEURIRejectsOver2048Bytes(t *testing.T) {
// 2200-byte external_id forces the assembled URI past 2048 bytes
// regardless of any other field.
tooLong := strings.Repeat("a", 2200)

_, err := BuildWIMSEURI("highflame.ai", "acct", "proj", IdentityTypeAgent, tooLong)
if err == nil {
t.Fatalf("expected error for SPIFFE ID > %d bytes", MaxSPIFFEIDBytes)
}
if !strings.Contains(err.Error(), "exceeds 2048 bytes") {
t.Fatalf("error must name the cap so callers can act on it; got %q", err.Error())
}
}

// TestBuildWIMSEURIAcceptsTypicalSize covers the happy path so we'd notice
// if a future change tightened the cap by accident.
func TestBuildWIMSEURIAcceptsTypicalSize(t *testing.T) {
uri, err := BuildWIMSEURI("highflame.ai", "acct-001", "proj-001", IdentityTypeAgent, "agent-1")
if err != nil {
t.Fatalf("unexpected error for short URI: %v", err)
}
want := "spiffe://highflame.ai/acct-001/proj-001/agent/agent-1"
if uri != want {
t.Fatalf("URI shape changed: got %q, want %q", uri, want)
}
}

// TestBuildWIMSEURIBoundary checks the inclusive boundary at exactly the
// cap. 2048 bytes must succeed; 2049 must fail.
func TestBuildWIMSEURIBoundary(t *testing.T) {
// Prefix length: "spiffe://" + "d" + "/" + "a" + "/" + "p" + "/" +
// "agent" + "/" = 9 + 1 + 1 + 1 + 1 + 1 + 1 + 5 + 1 = 21.
prefixLen := len("spiffe://d/a/p/agent/")
atCap := strings.Repeat("a", MaxSPIFFEIDBytes-prefixLen)

if _, err := BuildWIMSEURI("d", "a", "p", IdentityTypeAgent, atCap); err != nil {
t.Fatalf("URI of exactly %d bytes should be allowed: %v", MaxSPIFFEIDBytes, err)
}

overCap := atCap + "a"
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.

should we assert the error is actually the length exceeded ?

if _, err := BuildWIMSEURI("d", "a", "p", IdentityTypeAgent, overCap); err == nil {
t.Fatalf("URI of %d bytes should be rejected", MaxSPIFFEIDBytes+1)
}
}
7 changes: 6 additions & 1 deletion internal/service/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,18 @@ func (s *IdentityService) RegisterIdentity(ctx context.Context, req RegisterIden
return nil, err
}

wimseURI, err := domain.BuildWIMSEURI(s.wimseDomain, req.AccountID, req.ProjectID, req.IdentityType, req.ExternalID)
if err != nil {
return nil, err
}
Comment on lines +188 to +191
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.

medium

To maintain consistency with other error handling in RegisterIdentity (e.g., line 196) and across the service, consider wrapping the error from domain.BuildWIMSEURI. This ensures that the error trace includes the context of the operation being performed.

Suggested change
wimseURI, err := domain.BuildWIMSEURI(s.wimseDomain, req.AccountID, req.ProjectID, req.IdentityType, req.ExternalID)
if err != nil {
return nil, err
}
wimseURI, err := domain.BuildWIMSEURI(s.wimseDomain, req.AccountID, req.ProjectID, req.IdentityType, req.ExternalID)
if err != nil {
return nil, fmt.Errorf("failed to build WIMSE URI: %w", err)
}


identity := &domain.Identity{
ID: uuid.New().String(),
AccountID: req.AccountID,
ProjectID: req.ProjectID,
ExternalID: req.ExternalID,
Name: req.Name,
WIMSEURI: domain.BuildWIMSEURI(s.wimseDomain, req.AccountID, req.ProjectID, req.IdentityType, req.ExternalID),
WIMSEURI: wimseURI,
IdentityType: req.IdentityType,
SubType: req.SubType,
TrustLevel: req.TrustLevel,
Expand Down