Skip to content
Closed
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
115 changes: 115 additions & 0 deletions internal/api/scim/core/attribute.go
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
}
171 changes: 171 additions & 0 deletions internal/api/scim/core/attribute_test.go
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"`)
})
}
6 changes: 0 additions & 6 deletions internal/api/scim/core/endpoints.go

This file was deleted.

25 changes: 25 additions & 0 deletions internal/api/scim/core/kind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package core

import "strings"

type Kind struct {

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.

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.go or internal/api/scim/core/resource_type.go)?

Copy link
Copy Markdown
Contributor Author

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.

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, "/")
}
33 changes: 33 additions & 0 deletions internal/api/scim/core/kind_test.go
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", ""))
})
}
18 changes: 15 additions & 3 deletions internal/api/scim/core/meta.go
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())

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.

Severity: LOW

resource.ResourceID() is copied into the SCIM meta.location URL as a raw path fragment. A client- or directory-derived identifier containing /, ?, #, or .. can change the referenced path or query, causing SCIM consumers following this location to request an unintended resource or operation.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: URL-encode the resource ID before appending it to the SCIM meta.location URL to prevent path traversal and URL manipulation via special characters. Use url.PathEscape(resource.ResourceID()) at line 23, and also expand the import at line 3 to include "net/url" alongside "time". url.PathEscape will percent-encode characters such as /, ?, #, and .. so they are treated as literal data in the path segment rather than URL structure.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
m.Location = Join(m.Location, resource.ResourceID())
m.Location = Join(m.Location, url.PathEscape(resource.ResourceID()))

m.Created, m.LastModified = created.UTC(), updated.UTC()
return m
}
Loading
Loading