Skip to content

Commit ee648cd

Browse files
committed
feat(scim): add User ResourceType and Schema types
1 parent 15f846b commit ee648cd

17 files changed

Lines changed: 821 additions & 45 deletions
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package core
2+
3+
// AttributeType is the data type of an attribute, per RFC 7643, Section 7.
4+
type AttributeType string
5+
6+
const (
7+
TypeString AttributeType = "string"
8+
TypeBoolean AttributeType = "boolean"
9+
TypeDecimal AttributeType = "decimal"
10+
TypeInteger AttributeType = "integer"
11+
TypeDateTime AttributeType = "dateTime"
12+
TypeReference AttributeType = "reference"
13+
TypeComplex AttributeType = "complex"
14+
)
15+
16+
// Mutability states when an attribute may be (re)defined.
17+
type Mutability string
18+
19+
const (
20+
MutabilityReadOnly Mutability = "readOnly"
21+
MutabilityReadWrite Mutability = "readWrite"
22+
MutabilityImmutable Mutability = "immutable"
23+
MutabilityWriteOnly Mutability = "writeOnly"
24+
)
25+
26+
// Returned states when an attribute is included in a response.
27+
type Returned string
28+
29+
const (
30+
ReturnedAlways Returned = "always"
31+
ReturnedNever Returned = "never"
32+
ReturnedDefault Returned = "default"
33+
ReturnedRequest Returned = "request"
34+
)
35+
36+
// Uniqueness states how the service provider enforces uniqueness.
37+
type Uniqueness string
38+
39+
const (
40+
UniquenessNone Uniqueness = "none"
41+
UniquenessServer Uniqueness = "server"
42+
UniquenessGlobal Uniqueness = "global"
43+
)
44+
45+
// The reference types of RFC 7643, Section 7 that are not resource types.
46+
const (
47+
ReferenceExternal = "external"
48+
ReferenceURI = "uri"
49+
)
50+
51+
// Attribute describes one attribute of a schema, per RFC 7643, Section 7.
52+
type Attribute struct {
53+
Name string `json:"name"`
54+
Type AttributeType `json:"type"`
55+
MultiValued bool `json:"multiValued"`
56+
Description string `json:"description"`
57+
Required bool `json:"required"`
58+
CanonicalValues []string `json:"canonicalValues,omitempty"`
59+
CaseExact bool `json:"caseExact"`
60+
Mutability Mutability `json:"mutability"`
61+
Returned Returned `json:"returned"`
62+
Uniqueness Uniqueness `json:"uniqueness"`
63+
ReferenceTypes []string `json:"referenceTypes,omitempty"`
64+
SubAttributes []*Attribute `json:"subAttributes,omitempty"`
65+
}
66+
67+
func NewAttribute(name string, attributeType AttributeType, description string) *Attribute {
68+
return &Attribute{
69+
Name: name,
70+
Type: attributeType,
71+
Description: description,
72+
Mutability: MutabilityReadWrite,
73+
Returned: ReturnedDefault,
74+
Uniqueness: UniquenessNone,
75+
}
76+
}
77+
78+
func (a *Attribute) AsRequired() *Attribute {
79+
a.Required = true
80+
return a
81+
}
82+
83+
func (a *Attribute) AsMultiValued() *Attribute {
84+
a.MultiValued = true
85+
return a
86+
}
87+
88+
func (a *Attribute) AsCaseExact() *Attribute {
89+
a.CaseExact = true
90+
return a
91+
}
92+
93+
// Suggesting sets "canonicalValues", the values a client may send for this
94+
// attribute, e.g. "work" and "home".
95+
func (a *Attribute) Suggesting(values ...string) *Attribute {
96+
a.CanonicalValues = values
97+
return a
98+
}
99+
100+
// Referencing sets "referenceTypes", the resource types a reference attribute
101+
// may point at, either by name or as ReferenceExternal or ReferenceURI.
102+
func (a *Attribute) Referencing(referenceTypes ...string) *Attribute {
103+
a.ReferenceTypes = referenceTypes
104+
return a
105+
}
106+
107+
func (a *Attribute) UniqueOn(uniqueness Uniqueness) *Attribute {
108+
a.Uniqueness = uniqueness
109+
return a
110+
}
111+
112+
func (a *Attribute) With(subAttributes ...*Attribute) *Attribute {
113+
a.SubAttributes = subAttributes
114+
return a
115+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package core
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestNewAttribute(t *testing.T) {
12+
attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.")
13+
14+
t.Run("describes the attribute it names", func(t *testing.T) {
15+
assert.Equal(t, "userName", attribute.Name)
16+
assert.Equal(t, TypeString, attribute.Type)
17+
assert.Equal(t, "A unique identifier for the user.", attribute.Description)
18+
})
19+
20+
t.Run("defaults to a readWrite attribute returned by default", func(t *testing.T) {
21+
assert.Equal(t, MutabilityReadWrite, attribute.Mutability)
22+
assert.Equal(t, ReturnedDefault, attribute.Returned)
23+
assert.Equal(t, UniquenessNone, attribute.Uniqueness)
24+
})
25+
26+
t.Run("defaults to an optional, single valued, case insensitive attribute", func(t *testing.T) {
27+
assert.False(t, attribute.Required)
28+
assert.False(t, attribute.MultiValued)
29+
assert.False(t, attribute.CaseExact)
30+
})
31+
32+
t.Run("serializes to JSON correctly", func(t *testing.T) {
33+
body, err := json.Marshal(attribute)
34+
35+
require.NoError(t, err)
36+
require.JSONEq(t, `{
37+
"name": "userName",
38+
"type": "string",
39+
"multiValued": false,
40+
"description": "A unique identifier for the user.",
41+
"required": false,
42+
"caseExact": false,
43+
"mutability": "readWrite",
44+
"returned": "default",
45+
"uniqueness": "none"
46+
}`, string(body))
47+
})
48+
}
49+
50+
func TestAttribute(t *testing.T) {
51+
t.Run("marks the attribute the client must send", func(t *testing.T) {
52+
attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.")
53+
54+
require.Same(t, attribute, attribute.AsRequired())
55+
assert.True(t, attribute.Required)
56+
})
57+
58+
t.Run("marks the attribute that holds more than one value", func(t *testing.T) {
59+
attribute := NewAttribute("emails", TypeComplex, "The email addresses for the user.")
60+
61+
require.Same(t, attribute, attribute.AsMultiValued())
62+
assert.True(t, attribute.MultiValued)
63+
})
64+
65+
t.Run("marks the attribute whose value is compared case sensitively", func(t *testing.T) {
66+
attribute := NewAttribute("id", TypeString, "A unique identifier for the resource.")
67+
68+
require.Same(t, attribute, attribute.AsCaseExact())
69+
assert.True(t, attribute.CaseExact)
70+
})
71+
72+
t.Run("states the scope the service provider enforces uniqueness over", func(t *testing.T) {
73+
attribute := NewAttribute("userName", TypeString, "A unique identifier for the user.")
74+
75+
require.Same(t, attribute, attribute.UniqueOn(UniquenessServer))
76+
77+
body, err := json.Marshal(attribute)
78+
79+
require.NoError(t, err)
80+
require.Contains(t, string(body), `"uniqueness":"server"`)
81+
})
82+
83+
t.Run("suggests the canonical values a client may send", func(t *testing.T) {
84+
attribute := NewAttribute("type", TypeString, "A label indicating the attribute's function.").
85+
Suggesting("work", "home", "other")
86+
87+
body, err := json.Marshal(attribute)
88+
89+
require.NoError(t, err)
90+
require.Contains(t, string(body), `"canonicalValues":["work","home","other"]`)
91+
})
92+
93+
t.Run("names the resource types a reference may point at", func(t *testing.T) {
94+
attribute := NewAttribute("$ref", TypeReference, "The URI of the corresponding resource.")
95+
96+
require.Same(t, attribute, attribute.Referencing(string(KindUser.Name), ReferenceExternal, ReferenceURI))
97+
98+
body, err := json.Marshal(attribute)
99+
100+
require.NoError(t, err)
101+
require.Contains(t, string(body), `"referenceTypes":["User","external","uri"]`)
102+
})
103+
104+
t.Run("nests the sub-attributes of a complex attribute", func(t *testing.T) {
105+
givenName := NewAttribute("givenName", TypeString, "The given name of the user.")
106+
name := NewAttribute("name", TypeComplex, "The components of the user's name.")
107+
108+
require.Same(t, name, name.With(givenName))
109+
require.Equal(t, []*Attribute{givenName}, name.SubAttributes)
110+
111+
body, err := json.Marshal(name)
112+
113+
require.NoError(t, err)
114+
require.JSONEq(t, `{
115+
"name": "name",
116+
"type": "complex",
117+
"multiValued": false,
118+
"description": "The components of the user's name.",
119+
"required": false,
120+
"caseExact": false,
121+
"mutability": "readWrite",
122+
"returned": "default",
123+
"uniqueness": "none",
124+
"subAttributes": [{
125+
"name": "givenName",
126+
"type": "string",
127+
"multiValued": false,
128+
"description": "The given name of the user.",
129+
"required": false,
130+
"caseExact": false,
131+
"mutability": "readWrite",
132+
"returned": "default",
133+
"uniqueness": "none"
134+
}]
135+
}`, string(body))
136+
})
137+
138+
t.Run("composes every refinement in a chain", func(t *testing.T) {
139+
attribute := NewAttribute("emails", TypeComplex, "The email addresses for the user.").
140+
AsRequired().
141+
AsMultiValued().
142+
AsCaseExact().
143+
UniqueOn(UniquenessGlobal).
144+
Suggesting("work", "home").
145+
With(NewAttribute("value", TypeString, "The email address."))
146+
147+
assert.True(t, attribute.Required)
148+
assert.True(t, attribute.MultiValued)
149+
assert.True(t, attribute.CaseExact)
150+
assert.Equal(t, UniquenessGlobal, attribute.Uniqueness)
151+
assert.Equal(t, []string{"work", "home"}, attribute.CanonicalValues)
152+
assert.Len(t, attribute.SubAttributes, 1)
153+
154+
body, err := json.Marshal(attribute)
155+
156+
require.NoError(t, err)
157+
require.Contains(t, string(body), `"uniqueness":"global"`)
158+
})
159+
160+
t.Run("serializes an attribute the client can neither write nor read back", func(t *testing.T) {
161+
attribute := NewAttribute("password", TypeString, "The user's cleartext password.")
162+
attribute.Mutability = MutabilityWriteOnly
163+
attribute.Returned = ReturnedNever
164+
165+
body, err := json.Marshal(attribute)
166+
167+
require.NoError(t, err)
168+
require.Contains(t, string(body), `"mutability":"writeOnly"`)
169+
require.Contains(t, string(body), `"returned":"never"`)
170+
})
171+
}

internal/api/scim/core/endpoints.go

Lines changed: 0 additions & 6 deletions
This file was deleted.

internal/api/scim/core/kind.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package core
2+
3+
import "strings"
4+
5+
type Kind struct {
6+
Name ResourceTypeName
7+
Schema SchemaURI
8+
Endpoint string
9+
}
10+
11+
var (
12+
KindGroup = Kind{Name: "Group", Schema: SchemaGroup, Endpoint: "/Groups"}
13+
KindResourceType = Kind{Name: "ResourceType", Schema: SchemaResourceType, Endpoint: "/ResourceTypes"}
14+
KindSchema = Kind{Name: "Schema", Schema: SchemaSchema, Endpoint: "/Schemas"}
15+
KindServiceProviderConfig = Kind{Name: "ServiceProviderConfig", Schema: SchemaServiceProviderConfig, Endpoint: "/ServiceProviderConfig"}
16+
KindUser = Kind{Name: "User", Schema: SchemaUser, Endpoint: "/Users"}
17+
)
18+
19+
func (k Kind) Location(baseURL string) string {
20+
return Join(baseURL, k.Endpoint)
21+
}
22+
23+
func Join(base, segment string) string {
24+
return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(segment, "/")
25+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package core
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestKindLocation(t *testing.T) {
10+
baseURL := "http://localhost:9999/scim/v2"
11+
12+
t.Run("locates the collection under the base URL", func(t *testing.T) {
13+
require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL))
14+
})
15+
16+
t.Run("does not double the separator when the base URL ends in a slash", func(t *testing.T) {
17+
require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL+"/"))
18+
})
19+
}
20+
21+
func TestJoin(t *testing.T) {
22+
t.Run("separates the segment from the base", func(t *testing.T) {
23+
require.Equal(t, "http://localhost:9999/Users", Join("http://localhost:9999", "Users"))
24+
})
25+
26+
t.Run("collapses the separators the caller supplied", func(t *testing.T) {
27+
require.Equal(t, "http://localhost:9999/Users", Join("http://localhost:9999/", "/Users"))
28+
})
29+
30+
t.Run("separates an empty segment from the base", func(t *testing.T) {
31+
require.Equal(t, "http://localhost:9999/", Join("http://localhost:9999", ""))
32+
})
33+
}

internal/api/scim/core/meta.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
package core
22

3+
import "time"
4+
35
// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1.
46
type Meta struct {
57
ResourceType ResourceTypeName `json:"resourceType"`
8+
Created time.Time `json:"created,omitzero"`
9+
LastModified time.Time `json:"lastModified,omitzero"`
610
Location string `json:"location,omitempty"`
11+
Version string `json:"version,omitempty"`
712
}
813

9-
func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta {
14+
func NewMeta(baseURL string, kind Kind) Meta {
1015
return Meta{
11-
ResourceType: resourceType,
12-
Location: baseURL + endpoint,
16+
ResourceType: kind.Name,
17+
Location: kind.Location(baseURL),
1318
}
1419
}
20+
21+
func (m Meta) For(resource Resource) Meta {
22+
created, updated := resource.Timestamps()
23+
m.Location = Join(m.Location, resource.ResourceID())
24+
m.Created, m.LastModified = created.UTC(), updated.UTC()
25+
return m
26+
}

0 commit comments

Comments
 (0)