-
Notifications
You must be signed in to change notification settings - Fork 751
feat: update scim/core to implement RFC-7643 #2746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"`) | ||
| }) | ||
| } |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, "/") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", "")) | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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()) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ Severity: LOW
💡 Fix SuggestionSuggestion: URL-encode the resource ID before appending it to the SCIM
Suggested change
|
||||||
| m.Created, m.LastModified = created.UTC(), updated.UTC() | ||||||
| return m | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this is subjective so please feel free to ignore — I find that "kind" a bit overloaded and seeing it in method signatures doesn't immediately signal what it's about.
Would it make sense to merge this with
Resource(internal/api/scim/core/resource.goorinternal/api/scim/core/resource_type.go)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree. I struggled with this type because I was trying to glue together concepts that don't really fit. I'm going to try dropping it and try Resource because I think that might fit better.