diff --git a/domain/identity.go b/domain/identity.go index 184160c..9a3418a 100644 --- a/domain/identity.go +++ b/domain/identity.go @@ -4,12 +4,18 @@ package domain import ( "encoding/json" + "errors" "fmt" "time" "github.com/uptrace/bun" ) +// ErrSPIFFEIDTooLong is returned by BuildWIMSEURI when the assembled URI +// exceeds MaxSPIFFEIDBytes. Callers can branch on this with errors.Is to +// distinguish the cap-exceeded case from generic build failures. +var ErrSPIFFEIDTooLong = errors.New("SPIFFE ID exceeds maximum length") + // ────────────────────────────────────────────────────────────────────────────── // Trust Level // ────────────────────────────────────────────────────────────────────────────── @@ -408,10 +414,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("%w: got %d bytes, max %d: %.64q", ErrSPIFFEIDTooLong, n, MaxSPIFFEIDBytes, uri) + } + return uri, nil } // ValidateSPIFFEPathSegment rejects values that wouldn't survive a round-trip diff --git a/domain/identity_test.go b/domain/identity_test.go new file mode 100644 index 0000000..b65fd53 --- /dev/null +++ b/domain/identity_test.go @@ -0,0 +1,59 @@ +package domain + +import ( + "errors" + "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 !errors.Is(err, ErrSPIFFEIDTooLong) { + t.Fatalf("error must wrap ErrSPIFFEIDTooLong so callers can branch 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" + _, err := BuildWIMSEURI("d", "a", "p", IdentityTypeAgent, overCap) + if err == nil { + t.Fatalf("URI of %d bytes should be rejected", MaxSPIFFEIDBytes+1) + } + if !errors.Is(err, ErrSPIFFEIDTooLong) { + t.Fatalf("error must wrap ErrSPIFFEIDTooLong so callers can branch on it; got %q", err.Error()) + } +} diff --git a/internal/service/identity.go b/internal/service/identity.go index f61ce0a..35c6c97 100644 --- a/internal/service/identity.go +++ b/internal/service/identity.go @@ -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 + } + 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,