From 7696ca9db60fed6f7422a4e28ab446bb8d140a19 Mon Sep 17 00:00:00 2001 From: mo khan Date: Tue, 25 Aug 2026 18:47:24 -0600 Subject: [PATCH 1/2] feat(scim): add User ResourceType and Schema types --- internal/api/scim/core/attribute.go | 115 ++++++++++++ internal/api/scim/core/attribute_test.go | 171 ++++++++++++++++++ internal/api/scim/core/endpoints.go | 6 - internal/api/scim/core/kind.go | 25 +++ internal/api/scim/core/kind_test.go | 33 ++++ internal/api/scim/core/meta.go | 18 +- internal/api/scim/core/meta_test.go | 88 +++++++-- internal/api/scim/core/resource.go | 8 + internal/api/scim/core/resource_type.go | 48 +++++ internal/api/scim/core/resource_type_test.go | 72 ++++++++ internal/api/scim/core/schema.go | 38 ++++ internal/api/scim/core/schema_test.go | 83 +++++++++ internal/api/scim/core/schemas.go | 15 +- .../api/scim/core/service_provider_config.go | 41 ++++- .../scim/core/service_provider_config_test.go | 18 +- internal/api/scim/core/user.go | 26 +++ internal/api/scim/core/user_test.go | 61 +++++++ 17 files changed, 821 insertions(+), 45 deletions(-) create mode 100644 internal/api/scim/core/attribute.go create mode 100644 internal/api/scim/core/attribute_test.go delete mode 100644 internal/api/scim/core/endpoints.go create mode 100644 internal/api/scim/core/kind.go create mode 100644 internal/api/scim/core/kind_test.go create mode 100644 internal/api/scim/core/resource.go create mode 100644 internal/api/scim/core/resource_type.go create mode 100644 internal/api/scim/core/resource_type_test.go create mode 100644 internal/api/scim/core/schema.go create mode 100644 internal/api/scim/core/schema_test.go create mode 100644 internal/api/scim/core/user.go create mode 100644 internal/api/scim/core/user_test.go diff --git a/internal/api/scim/core/attribute.go b/internal/api/scim/core/attribute.go new file mode 100644 index 0000000000..ae89b95962 --- /dev/null +++ b/internal/api/scim/core/attribute.go @@ -0,0 +1,115 @@ +package core + +// AttributeType is the data type of an attribute, per RFC 7643, Section 7. +type AttributeType string + +const ( + TypeString AttributeType = "string" + TypeBoolean AttributeType = "boolean" + TypeDecimal AttributeType = "decimal" + TypeInteger AttributeType = "integer" + TypeDateTime AttributeType = "dateTime" + TypeReference AttributeType = "reference" + TypeComplex AttributeType = "complex" +) + +// Mutability states when an attribute may be (re)defined. +type Mutability string + +const ( + MutabilityReadOnly Mutability = "readOnly" + MutabilityReadWrite Mutability = "readWrite" + MutabilityImmutable Mutability = "immutable" + MutabilityWriteOnly Mutability = "writeOnly" +) + +// Returned states when an attribute is included in a response. +type Returned string + +const ( + ReturnedAlways Returned = "always" + ReturnedNever Returned = "never" + ReturnedDefault Returned = "default" + ReturnedRequest Returned = "request" +) + +// Uniqueness states how the service provider enforces uniqueness. +type Uniqueness string + +const ( + UniquenessNone Uniqueness = "none" + UniquenessServer Uniqueness = "server" + UniquenessGlobal Uniqueness = "global" +) + +// The reference types of RFC 7643, Section 7 that are not resource types. +const ( + ReferenceExternal = "external" + ReferenceURI = "uri" +) + +// Attribute describes one attribute of a schema, per RFC 7643, Section 7. +type Attribute struct { + Name string `json:"name"` + Type AttributeType `json:"type"` + MultiValued bool `json:"multiValued"` + Description string `json:"description"` + Required bool `json:"required"` + CanonicalValues []string `json:"canonicalValues,omitempty"` + CaseExact bool `json:"caseExact"` + Mutability Mutability `json:"mutability"` + Returned Returned `json:"returned"` + Uniqueness Uniqueness `json:"uniqueness"` + ReferenceTypes []string `json:"referenceTypes,omitempty"` + SubAttributes []*Attribute `json:"subAttributes,omitempty"` +} + +func NewAttribute(name string, attributeType AttributeType, description string) *Attribute { + return &Attribute{ + Name: name, + Type: attributeType, + Description: description, + Mutability: MutabilityReadWrite, + Returned: ReturnedDefault, + Uniqueness: UniquenessNone, + } +} + +func (a *Attribute) AsRequired() *Attribute { + a.Required = true + return a +} + +func (a *Attribute) AsMultiValued() *Attribute { + a.MultiValued = true + return a +} + +func (a *Attribute) AsCaseExact() *Attribute { + a.CaseExact = true + return a +} + +// Suggesting sets "canonicalValues", the values a client may send for this +// attribute, e.g. "work" and "home". +func (a *Attribute) Suggesting(values ...string) *Attribute { + a.CanonicalValues = values + return a +} + +// Referencing sets "referenceTypes", the resource types a reference attribute +// may point at, either by name or as ReferenceExternal or ReferenceURI. +func (a *Attribute) Referencing(referenceTypes ...string) *Attribute { + a.ReferenceTypes = referenceTypes + return a +} + +func (a *Attribute) UniqueOn(uniqueness Uniqueness) *Attribute { + a.Uniqueness = uniqueness + return a +} + +func (a *Attribute) With(subAttributes ...*Attribute) *Attribute { + a.SubAttributes = subAttributes + return a +} diff --git a/internal/api/scim/core/attribute_test.go b/internal/api/scim/core/attribute_test.go new file mode 100644 index 0000000000..c93be13bbe --- /dev/null +++ b/internal/api/scim/core/attribute_test.go @@ -0,0 +1,171 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAttribute(t *testing.T) { + attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.") + + t.Run("describes the attribute it names", func(t *testing.T) { + assert.Equal(t, "userName", attribute.Name) + assert.Equal(t, TypeString, attribute.Type) + assert.Equal(t, "A unique identifier for the user.", attribute.Description) + }) + + t.Run("defaults to a readWrite attribute returned by default", func(t *testing.T) { + assert.Equal(t, MutabilityReadWrite, attribute.Mutability) + assert.Equal(t, ReturnedDefault, attribute.Returned) + assert.Equal(t, UniquenessNone, attribute.Uniqueness) + }) + + t.Run("defaults to an optional, single valued, case insensitive attribute", func(t *testing.T) { + assert.False(t, attribute.Required) + assert.False(t, attribute.MultiValued) + assert.False(t, attribute.CaseExact) + }) + + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.JSONEq(t, `{ + "name": "userName", + "type": "string", + "multiValued": false, + "description": "A unique identifier for the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }`, string(body)) + }) +} + +func TestAttribute(t *testing.T) { + t.Run("marks the attribute the client must send", func(t *testing.T) { + attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.") + + require.Same(t, attribute, attribute.AsRequired()) + assert.True(t, attribute.Required) + }) + + t.Run("marks the attribute that holds more than one value", func(t *testing.T) { + attribute := NewAttribute("emails", TypeComplex, "The email addresses for the user.") + + require.Same(t, attribute, attribute.AsMultiValued()) + assert.True(t, attribute.MultiValued) + }) + + t.Run("marks the attribute whose value is compared case sensitively", func(t *testing.T) { + attribute := NewAttribute("id", TypeString, "A unique identifier for the resource.") + + require.Same(t, attribute, attribute.AsCaseExact()) + assert.True(t, attribute.CaseExact) + }) + + t.Run("states the scope the service provider enforces uniqueness over", func(t *testing.T) { + attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.") + + require.Same(t, attribute, attribute.UniqueOn(UniquenessServer)) + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"uniqueness":"server"`) + }) + + t.Run("suggests the canonical values a client may send", func(t *testing.T) { + attribute := NewAttribute("type", TypeString, "A label indicating the attribute's function."). + Suggesting("work", "home", "other") + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"canonicalValues":["work","home","other"]`) + }) + + t.Run("names the resource types a reference may point at", func(t *testing.T) { + attribute := NewAttribute("$ref", TypeReference, "The URI of the corresponding resource.") + + require.Same(t, attribute, attribute.Referencing(string(KindUser.Name), ReferenceExternal, ReferenceURI)) + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"referenceTypes":["User","external","uri"]`) + }) + + t.Run("nests the sub-attributes of a complex attribute", func(t *testing.T) { + givenName := NewAttribute("givenName", TypeString, "The given name of the user.") + name := NewAttribute("name", TypeComplex, "The components of the user's name.") + + require.Same(t, name, name.With(givenName)) + require.Equal(t, []*Attribute{givenName}, name.SubAttributes) + + body, err := json.Marshal(name) + + require.NoError(t, err) + require.JSONEq(t, `{ + "name": "name", + "type": "complex", + "multiValued": false, + "description": "The components of the user's name.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [{ + "name": "givenName", + "type": "string", + "multiValued": false, + "description": "The given name of the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }] + }`, string(body)) + }) + + t.Run("composes every refinement in a chain", func(t *testing.T) { + attribute := NewAttribute("emails", TypeComplex, "The email addresses for the user."). + AsRequired(). + AsMultiValued(). + AsCaseExact(). + UniqueOn(UniquenessGlobal). + Suggesting("work", "home"). + With(NewAttribute("value", TypeString, "The email address.")) + + assert.True(t, attribute.Required) + assert.True(t, attribute.MultiValued) + assert.True(t, attribute.CaseExact) + assert.Equal(t, UniquenessGlobal, attribute.Uniqueness) + assert.Equal(t, []string{"work", "home"}, attribute.CanonicalValues) + assert.Len(t, attribute.SubAttributes, 1) + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"uniqueness":"global"`) + }) + + t.Run("serializes an attribute the client can neither write nor read back", func(t *testing.T) { + attribute := NewAttribute("password", TypeString, "The user's cleartext password.") + attribute.Mutability = MutabilityWriteOnly + attribute.Returned = ReturnedNever + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"mutability":"writeOnly"`) + require.Contains(t, string(body), `"returned":"never"`) + }) +} diff --git a/internal/api/scim/core/endpoints.go b/internal/api/scim/core/endpoints.go deleted file mode 100644 index b1f9003dfb..0000000000 --- a/internal/api/scim/core/endpoints.go +++ /dev/null @@ -1,6 +0,0 @@ -package core - -// The resource endpoints of RFC 7644, Section 3.2, relative to the base URL -const ( - EndpointServiceProviderConfig = "/ServiceProviderConfig" -) diff --git a/internal/api/scim/core/kind.go b/internal/api/scim/core/kind.go new file mode 100644 index 0000000000..d75e5115cf --- /dev/null +++ b/internal/api/scim/core/kind.go @@ -0,0 +1,25 @@ +package core + +import "strings" + +type Kind struct { + Name ResourceTypeName + Schema SchemaURI + Endpoint string +} + +var ( + KindGroup = Kind{Name: "Group", Schema: SchemaGroup, Endpoint: "/Groups"} + KindResourceType = Kind{Name: "ResourceType", Schema: SchemaResourceType, Endpoint: "/ResourceTypes"} + KindSchema = Kind{Name: "Schema", Schema: SchemaSchema, Endpoint: "/Schemas"} + KindServiceProviderConfig = Kind{Name: "ServiceProviderConfig", Schema: SchemaServiceProviderConfig, Endpoint: "/ServiceProviderConfig"} + KindUser = Kind{Name: "User", Schema: SchemaUser, Endpoint: "/Users"} +) + +func (k Kind) Location(baseURL string) string { + return Join(baseURL, k.Endpoint) +} + +func Join(base, segment string) string { + return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(segment, "/") +} diff --git a/internal/api/scim/core/kind_test.go b/internal/api/scim/core/kind_test.go new file mode 100644 index 0000000000..27b39454d3 --- /dev/null +++ b/internal/api/scim/core/kind_test.go @@ -0,0 +1,33 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKindLocation(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("locates the collection under the base URL", func(t *testing.T) { + require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL)) + }) + + t.Run("does not double the separator when the base URL ends in a slash", func(t *testing.T) { + require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL+"/")) + }) +} + +func TestJoin(t *testing.T) { + t.Run("separates the segment from the base", func(t *testing.T) { + require.Equal(t, "http://localhost:9999/Users", Join("http://localhost:9999", "Users")) + }) + + t.Run("collapses the separators the caller supplied", func(t *testing.T) { + require.Equal(t, "http://localhost:9999/Users", Join("http://localhost:9999/", "/Users")) + }) + + t.Run("separates an empty segment from the base", func(t *testing.T) { + require.Equal(t, "http://localhost:9999/", Join("http://localhost:9999", "")) + }) +} diff --git a/internal/api/scim/core/meta.go b/internal/api/scim/core/meta.go index a47e4a4b30..6465066db5 100644 --- a/internal/api/scim/core/meta.go +++ b/internal/api/scim/core/meta.go @@ -1,14 +1,26 @@ package core +import "time" + // Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1. type Meta struct { ResourceType ResourceTypeName `json:"resourceType"` + Created time.Time `json:"created,omitzero"` + LastModified time.Time `json:"lastModified,omitzero"` Location string `json:"location,omitempty"` + Version string `json:"version,omitempty"` } -func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta { +func NewMeta(baseURL string, kind Kind) Meta { return Meta{ - ResourceType: resourceType, - Location: baseURL + endpoint, + ResourceType: kind.Name, + Location: kind.Location(baseURL), } } + +func (m Meta) For(resource Resource) Meta { + created, updated := resource.Timestamps() + m.Location = Join(m.Location, resource.ResourceID()) + m.Created, m.LastModified = created.UTC(), updated.UTC() + return m +} diff --git a/internal/api/scim/core/meta_test.go b/internal/api/scim/core/meta_test.go index 4b7383bd70..e3dddce13d 100644 --- a/internal/api/scim/core/meta_test.go +++ b/internal/api/scim/core/meta_test.go @@ -3,37 +3,85 @@ package core import ( "encoding/json" "testing" + "time" + "github.com/gofrs/uuid" "github.com/stretchr/testify/require" ) -func TestNewMeta(t *testing.T) { - t.Run("locates the resource at its endpoint", func(t *testing.T) { - meta := NewMeta("http://localhost:9999/scim/v2", ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig) - - require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType) - require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location) - }) +type exampleUser struct { + id string + created, updated time.Time } +func (s exampleUser) ResourceID() string { return s.id } +func (s exampleUser) Timestamps() (created, updated time.Time) { return s.created, s.updated } + func TestMeta(t *testing.T) { - t.Run("serializes to JSON correctly", func(t *testing.T) { - body, err := json.Marshal(Meta{ - ResourceType: ResourceTypeServiceProviderConfig, - Location: "http://localhost:9999/scim/v2/ServiceProviderConfig", + baseURL := "http://localhost:9999/scim/v2" + + t.Run("NewMeta", func(t *testing.T) { + meta := NewMeta(baseURL, KindServiceProviderConfig) + + require.Equal(t, KindServiceProviderConfig.Name, meta.ResourceType) + require.Equal(t, baseURL+"/ServiceProviderConfig", meta.Location) + require.Zero(t, meta.Created) + require.Zero(t, meta.LastModified) + }) + + t.Run("For", func(t *testing.T) { + t.Run("locates the resource under its collection", func(t *testing.T) { + resource := exampleUser{ + id: uuid.Must(uuid.NewV4()).String(), + created: time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC), + updated: time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC), + } + + meta := NewMeta(baseURL, KindUser).For(resource) + + require.Equal(t, KindUser.Name, meta.ResourceType) + require.Equal(t, baseURL+"/Users/"+resource.id, meta.Location) + require.Equal(t, resource.created, meta.Created) + require.Equal(t, resource.updated, meta.LastModified) }) - require.NoError(t, err) - require.JSONEq(t, `{ - "resourceType": "ServiceProviderConfig", - "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" - }`, string(body)) + t.Run("restates the timestamps of the resource in UTC", func(t *testing.T) { + eastern := time.FixedZone("EST", -5*60*60) + resource := exampleUser{ + id: "2819c223", + created: time.Date(2026, 7, 21, 19, 41, 41, 0, eastern), + updated: time.Date(2026, 7, 22, 3, 12, 3, 0, eastern), + } + + meta := NewMeta(baseURL, KindUser).For(resource) + + require.Equal(t, time.UTC, meta.Created.Location()) + require.Equal(t, time.UTC, meta.LastModified.Location()) + + body, err := json.Marshal(meta) + + require.NoError(t, err) + require.Contains(t, string(body), `"created":"2026-07-22T00:41:41Z"`) + require.Contains(t, string(body), `"lastModified":"2026-07-22T08:12:03Z"`) + }) }) - t.Run("omits the location when it is empty", func(t *testing.T) { - body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig}) + t.Run("json.Marshal", func(t *testing.T) { + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(NewMeta(baseURL, KindServiceProviderConfig)) - require.NoError(t, err) - require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body)) + require.NoError(t, err) + require.JSONEq(t, `{ + "resourceType": "ServiceProviderConfig", + "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" + }`, string(body)) + }) + + t.Run("omits the location when it is empty", func(t *testing.T) { + body, err := json.Marshal(Meta{ResourceType: "ServiceProviderConfig"}) + + require.NoError(t, err) + require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body)) + }) }) } diff --git a/internal/api/scim/core/resource.go b/internal/api/scim/core/resource.go new file mode 100644 index 0000000000..12b8a2e33d --- /dev/null +++ b/internal/api/scim/core/resource.go @@ -0,0 +1,8 @@ +package core + +import "time" + +type Resource interface { + ResourceID() string + Timestamps() (created, updated time.Time) +} diff --git a/internal/api/scim/core/resource_type.go b/internal/api/scim/core/resource_type.go new file mode 100644 index 0000000000..5b8f68696c --- /dev/null +++ b/internal/api/scim/core/resource_type.go @@ -0,0 +1,48 @@ +package core + +import "time" + +// SchemaExtension is a schema that extends a resource type, per RFC 7643, Section 6. +type SchemaExtension struct { + Schema SchemaURI `json:"schema"` + Required bool `json:"required"` +} + +// ResourceType is the resource type metadata defined in RFC 7643, Section 6. +type ResourceType struct { + Schemas []SchemaURI `json:"schemas,omitempty"` + ID ResourceTypeName `json:"id,omitempty"` + Name ResourceTypeName `json:"name"` + Description string `json:"description,omitempty"` + Endpoint string `json:"endpoint"` + Schema SchemaURI `json:"schema,omitempty"` + SchemaExtensions []SchemaExtension `json:"schemaExtensions,omitempty"` + Meta Meta `json:"meta,omitzero"` +} + +func NewResourceType(baseURL string, kind Kind, schema *Schema) *ResourceType { + resourceType := &ResourceType{ + Schemas: []SchemaURI{SchemaResourceType}, + ID: kind.Name, + Name: kind.Name, + Description: schema.Description, + Endpoint: kind.Endpoint, + Schema: schema.ID, + } + resourceType.Meta = NewMeta(baseURL, KindResourceType).For(resourceType) + + return resourceType +} + +func (r *ResourceType) Extend(extensions ...SchemaExtension) *ResourceType { + r.SchemaExtensions = append(r.SchemaExtensions, extensions...) + return r +} + +func (r ResourceType) Kind() Kind { + return Kind{Name: r.Name, Endpoint: r.Endpoint} +} + +func (r *ResourceType) ResourceID() string { return string(r.ID) } + +func (r *ResourceType) Timestamps() (created, updated time.Time) { return } diff --git a/internal/api/scim/core/resource_type_test.go b/internal/api/scim/core/resource_type_test.go new file mode 100644 index 0000000000..b6f1a9694c --- /dev/null +++ b/internal/api/scim/core/resource_type_test.go @@ -0,0 +1,72 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResourceType(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("NewResourceType", func(t *testing.T) { + schema := NewSchema(baseURL, KindUser).Describe("User Account") + + resourceType := NewResourceType(baseURL, KindUser, schema) + + t.Run("takes its identity and description from the schema", func(t *testing.T) { + require.Equal(t, KindUser.Name, resourceType.ID) + require.Equal(t, KindUser.Name, resourceType.Name) + require.Equal(t, "User Account", resourceType.Description) + require.Equal(t, SchemaUser, resourceType.Schema) + }) + + t.Run("locates itself under the ResourceTypes endpoint", func(t *testing.T) { + require.Equal(t, KindResourceType.Name, resourceType.Meta.ResourceType) + require.Equal(t, baseURL+"/ResourceTypes/User", resourceType.Meta.Location) + }) + + t.Run("declares the schema extensions it was given", func(t *testing.T) { + extended := NewResourceType(baseURL, KindUser, schema). + Extend(SchemaExtension{Schema: SchemaEnterpriseUser, Required: true}) + + body, err := json.Marshal(extended) + + require.NoError(t, err) + require.Contains(t, string(body), + `"schemaExtensions":[{"schema":"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User","required":true}]`) + }) + + t.Run("declares no schema extensions until it is extended", func(t *testing.T) { + body, err := json.Marshal(NewResourceType(baseURL, KindUser, schema)) + + require.NoError(t, err) + require.NotContains(t, string(body), "schemaExtensions") + }) + }) + + t.Run("Extend", func(t *testing.T) { + schema := NewSchema(baseURL, KindUser) + enterprise := SchemaExtension{Schema: SchemaEnterpriseUser, Required: true} + + t.Run("keeps the extensions of an earlier call", func(t *testing.T) { + resourceType := NewResourceType(baseURL, KindUser, schema).Extend(enterprise) + + require.Same(t, resourceType, resourceType.Extend(SchemaExtension{Schema: SchemaGroup})) + require.Equal(t, []SchemaExtension{enterprise, {Schema: SchemaGroup}}, resourceType.SchemaExtensions) + }) + }) + + t.Run("Kind", func(t *testing.T) { + t.Run("names the kind and endpoint it was built from", func(t *testing.T) { + resourceType := NewResourceType(baseURL, KindUser, NewSchema(baseURL, KindUser)) + + kind := resourceType.Kind() + + require.Equal(t, KindUser.Name, kind.Name) + require.Equal(t, KindUser.Endpoint, kind.Endpoint) + require.Equal(t, baseURL+"/Users", kind.Location(baseURL)) + }) + }) +} diff --git a/internal/api/scim/core/schema.go b/internal/api/scim/core/schema.go new file mode 100644 index 0000000000..9cc7880213 --- /dev/null +++ b/internal/api/scim/core/schema.go @@ -0,0 +1,38 @@ +package core + +import "time" + +// Schema is the schema definition resource of RFC 7643, Section 7. +type Schema struct { + Schemas []SchemaURI `json:"schemas"` + ID SchemaURI `json:"id"` + Name ResourceTypeName `json:"name"` + Description string `json:"description"` + Attributes []*Attribute `json:"attributes"` + Meta Meta `json:"meta"` +} + +func NewSchema(baseURL string, kind Kind) *Schema { + schema := &Schema{ + Schemas: []SchemaURI{SchemaSchema}, + ID: kind.Schema, + Name: kind.Name, + } + schema.Meta = NewMeta(baseURL, KindSchema).For(schema) + + return schema +} + +func (s *Schema) Describe(description string) *Schema { + s.Description = description + return s +} + +func (s *Schema) With(attributes ...*Attribute) *Schema { + s.Attributes = attributes + return s +} + +func (s *Schema) ResourceID() string { return string(s.ID) } + +func (s *Schema) Timestamps() (created, updated time.Time) { return } diff --git a/internal/api/scim/core/schema_test.go b/internal/api/scim/core/schema_test.go new file mode 100644 index 0000000000..732cfdd813 --- /dev/null +++ b/internal/api/scim/core/schema_test.go @@ -0,0 +1,83 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSchema(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("NewSchema", func(t *testing.T) { + schema := NewSchema(baseURL, KindUser) + + t.Run("identifies itself by the URI of the kind it describes", func(t *testing.T) { + require.Equal(t, []SchemaURI{SchemaSchema}, schema.Schemas) + require.Equal(t, SchemaUser, schema.ID) + require.Equal(t, KindUser.Name, schema.Name) + }) + + t.Run("locates itself by URI under the Schemas endpoint", func(t *testing.T) { + require.Equal(t, KindSchema.Name, schema.Meta.ResourceType) + require.Equal(t, baseURL+"/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", schema.Meta.Location) + }) + + t.Run("carries no timestamps because a schema never changes", func(t *testing.T) { + created, updated := schema.Timestamps() + + require.Zero(t, created) + require.Zero(t, updated) + require.Zero(t, schema.Meta.Created) + require.Zero(t, schema.Meta.LastModified) + }) + }) + + t.Run("Describe", func(t *testing.T) { + schema := NewSchema(baseURL, KindUser) + + require.Same(t, schema, schema.Describe("User Account")) + assert.Equal(t, "User Account", schema.Description) + }) + + t.Run("With", func(t *testing.T) { + userName := NewAttribute("userName", TypeString, "A unique identifier for the user.") + schema := NewSchema(baseURL, KindUser) + + require.Same(t, schema, schema.With(userName)) + assert.Equal(t, []*Attribute{userName}, schema.Attributes) + }) + + t.Run("serializes to JSON correctly", func(t *testing.T) { + schema := NewSchema(baseURL, KindUser). + Describe("User Account"). + With(NewAttribute("userName", TypeString, "A unique identifier for the user.").AsRequired()) + + body, err := json.Marshal(schema) + + require.NoError(t, err) + require.JSONEq(t, `{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [{ + "name": "userName", + "type": "string", + "multiValued": false, + "description": "A unique identifier for the user.", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }], + "meta": { + "resourceType": "Schema", + "location": "http://localhost:9999/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + } + }`, string(body)) + }) +} diff --git a/internal/api/scim/core/schemas.go b/internal/api/scim/core/schemas.go index 128b2ea719..454183286b 100644 --- a/internal/api/scim/core/schemas.go +++ b/internal/api/scim/core/schemas.go @@ -2,12 +2,15 @@ package core // The schema URIs of RFC 7643 const ( - schemaRoot = "urn:ietf:params:scim:schemas" - schemaCore = schemaRoot + ":core:2.0" + schemaRoot = "urn:ietf:params:scim:schemas" + schemaCore = schemaRoot + ":core:2.0" + schemaExtension = schemaRoot + ":extension" - SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig" -) + SchemaEnterpriseUser SchemaURI = schemaExtension + ":enterprise:2.0:User" -const ( - ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig" + SchemaGroup SchemaURI = schemaCore + ":Group" + SchemaResourceType SchemaURI = schemaCore + ":ResourceType" + SchemaSchema SchemaURI = schemaCore + ":Schema" + SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig" + SchemaUser SchemaURI = schemaCore + ":User" ) diff --git a/internal/api/scim/core/service_provider_config.go b/internal/api/scim/core/service_provider_config.go index 26c64da947..fbe3521df4 100644 --- a/internal/api/scim/core/service_provider_config.go +++ b/internal/api/scim/core/service_provider_config.go @@ -17,17 +17,22 @@ type FilterFeature struct { type AuthenticationSchemeType string +// The authentication scheme types of RFC 7643, Section 5. const ( + AuthenticationSchemeOAuth AuthenticationSchemeType = "oauth" + AuthenticationSchemeOAuth2 AuthenticationSchemeType = "oauth2" AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken" + AuthenticationSchemeHTTPBasic AuthenticationSchemeType = "httpbasic" + AuthenticationSchemeHTTPDigest AuthenticationSchemeType = "httpdigest" ) -// AuthenticationScheme is the authentication scheme of RFC 7643, Section 5. type AuthenticationScheme struct { - Type AuthenticationSchemeType `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - SpecURI string `json:"specUri,omitempty"` - Primary bool `json:"primary"` + Type AuthenticationSchemeType `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + SpecURI string `json:"specUri,omitempty"` + DocumentationURI string `json:"documentationUri,omitempty"` + Primary bool `json:"primary"` } func NewOAuthBearerToken() *AuthenticationScheme { @@ -47,6 +52,7 @@ func (scheme *AuthenticationScheme) AsPrimary() *AuthenticationScheme { // ServiceProviderConfig is the schema defined in RFC 7643, Section 5. type ServiceProviderConfig struct { Schemas []SchemaURI `json:"schemas"` + DocumentationURI string `json:"documentationUri,omitempty"` Patch SupportedFeature `json:"patch"` Bulk BulkFeature `json:"bulk"` Filter FilterFeature `json:"filter"` @@ -57,14 +63,33 @@ type ServiceProviderConfig struct { Meta Meta `json:"meta"` } +// Sorting states that this provider honours "sortBy" and "sortOrder", per RFC 7644, Section 3.4.2.3. +func (c *ServiceProviderConfig) Sorting() *ServiceProviderConfig { + c.Sort.Supported = true + return c +} + +// Filtering states that this provider honours "filter" up to maxResults, per RFC 7644, Section 3.4.2.2. +func (c *ServiceProviderConfig) Filtering(maxResults int) *ServiceProviderConfig { + c.Filter.Supported = true + c.Filter.MaxResults = maxResults + return c +} + +// Patching states that this provider honours the PATCH request of RFC 7644, +// Section 3.5.2. +func (c *ServiceProviderConfig) Patching() *ServiceProviderConfig { + c.Patch.Supported = true + return c +} + func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) *ServiceProviderConfig { if schemes == nil { schemes = []*AuthenticationScheme{} } - return &ServiceProviderConfig{ Schemas: []SchemaURI{SchemaServiceProviderConfig}, AuthenticationSchemes: schemes, - Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig), + Meta: NewMeta(baseURL, KindServiceProviderConfig), } } diff --git a/internal/api/scim/core/service_provider_config_test.go b/internal/api/scim/core/service_provider_config_test.go index 03ff2dca95..f4d8981124 100644 --- a/internal/api/scim/core/service_provider_config_test.go +++ b/internal/api/scim/core/service_provider_config_test.go @@ -23,8 +23,8 @@ func TestNewServiceProviderConfig(t *testing.T) { config := NewServiceProviderConfig(baseURL) - require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType) - require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location) + require.Equal(t, ResourceTypeName("ServiceProviderConfig"), config.Meta.ResourceType) + require.Equal(t, baseURL+"/ServiceProviderConfig", config.Meta.Location) }) t.Run("supports none of the optional protocol features", func(t *testing.T) { @@ -38,6 +38,20 @@ func TestNewServiceProviderConfig(t *testing.T) { assert.False(t, config.ETag.Supported) }) + t.Run("Sorting claims support for sortBy and sortOrder", func(t *testing.T) { + config := NewServiceProviderConfig("").Sorting() + + assert.True(t, config.Sort.Supported) + assert.False(t, config.Filter.Supported, "claiming one feature claims no other") + }) + + t.Run("Sorting reaches the wire", func(t *testing.T) { + body, err := json.Marshal(NewServiceProviderConfig("").Sorting()) + + require.NoError(t, err) + require.Contains(t, string(body), `"sort":{"supported":true}`) + }) + t.Run("serializes authenticationSchemes as an array", func(t *testing.T) { body, err := json.Marshal(NewServiceProviderConfig("")) diff --git a/internal/api/scim/core/user.go b/internal/api/scim/core/user.go new file mode 100644 index 0000000000..11c5f25aeb --- /dev/null +++ b/internal/api/scim/core/user.go @@ -0,0 +1,26 @@ +package core + +type Email struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +// Name holds the components of the user's name, per RFC 7643, Section 4.1.1. +type Name struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` +} + +// User is the core User resource defined in RFC 7643, Section 4.1. +type User struct { + Schemas []SchemaURI `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName"` + Name Name `json:"name,omitzero"` + Emails []Email `json:"emails,omitempty"` + Active *bool `json:"active,omitempty"` + Meta Meta `json:"meta"` +} diff --git a/internal/api/scim/core/user_test.go b/internal/api/scim/core/user_test.go new file mode 100644 index 0000000000..6748165c96 --- /dev/null +++ b/internal/api/scim/core/user_test.go @@ -0,0 +1,61 @@ +package core + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestUser(t *testing.T) { + created := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC) + lastModified := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC) + + user := User{ + Schemas: []SchemaURI{SchemaUser}, + ID: "2819c223-7f76-453a-919d-413861904646", + ExternalID: "701984", + UserName: "bjensen@example.com", + Name: Name{Formatted: "Ms. Barbara J Jensen", FamilyName: "Jensen", GivenName: "Barbara"}, + Emails: []Email{{Value: "bjensen@example.com", Primary: true}}, + Active: new(true), + Meta: Meta{ + ResourceType: KindUser.Name, + Created: created, + LastModified: lastModified, + Location: "http://localhost:9999/scim/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + } + + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(user) + + require.NoError(t, err) + require.JSONEq(t, `{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "externalId": "701984", + "userName": "bjensen@example.com", + "name": {"formatted": "Ms. Barbara J Jensen", "familyName": "Jensen", "givenName": "Barbara"}, + "emails": [{"value": "bjensen@example.com", "primary": true}], + "active": true, + "meta": { + "resourceType": "User", + "created": "2026-07-21T19:41:41Z", + "lastModified": "2026-07-22T08:12:03Z", + "location": "http://localhost:9999/scim/v2/Users/2819c223-7f76-453a-919d-413861904646" + } + }`, string(body)) + }) + + t.Run("round-trips a deactivated user", func(t *testing.T) { + var decoded User + require.NoError(t, json.Unmarshal([]byte(`{"userName":"bjensen","active":false}`), &decoded)) + + body, err := json.Marshal(decoded) + + require.NoError(t, err) + require.Contains(t, string(body), `"active":false`) + }) +} From 29c862c8af2bec88d059f1e470d83a5179860486 Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 27 Aug 2026 15:23:22 -0600 Subject: [PATCH 2/2] fix: attach Schema --- internal/api/scim/core/resource_type.go | 2 +- internal/api/scim/core/resource_type_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/scim/core/resource_type.go b/internal/api/scim/core/resource_type.go index 5b8f68696c..b7ba375155 100644 --- a/internal/api/scim/core/resource_type.go +++ b/internal/api/scim/core/resource_type.go @@ -40,7 +40,7 @@ func (r *ResourceType) Extend(extensions ...SchemaExtension) *ResourceType { } func (r ResourceType) Kind() Kind { - return Kind{Name: r.Name, Endpoint: r.Endpoint} + return Kind{Name: r.Name, Schema: r.Schema, Endpoint: r.Endpoint} } func (r *ResourceType) ResourceID() string { return string(r.ID) } diff --git a/internal/api/scim/core/resource_type_test.go b/internal/api/scim/core/resource_type_test.go index b6f1a9694c..507e8b1a0f 100644 --- a/internal/api/scim/core/resource_type_test.go +++ b/internal/api/scim/core/resource_type_test.go @@ -65,6 +65,7 @@ func TestResourceType(t *testing.T) { kind := resourceType.Kind() require.Equal(t, KindUser.Name, kind.Name) + require.Equal(t, KindUser.Schema, kind.Schema) require.Equal(t, KindUser.Endpoint, kind.Endpoint) require.Equal(t, baseURL+"/Users", kind.Location(baseURL)) })