Skip to content

Commit 893274a

Browse files
committed
refactor: extract a provisioned user type
1 parent caef6e8 commit 893274a

10 files changed

Lines changed: 255 additions & 22 deletions

File tree

internal/api/scim/core/user.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,24 @@ type Email struct {
55
Primary bool `json:"primary"`
66
}
77

8+
// Name holds the components of the user's name, per RFC 7643, Section 4.1.1.
9+
type Name struct {
10+
Formatted string `json:"formatted,omitempty"`
11+
FamilyName string `json:"familyName,omitempty"`
12+
GivenName string `json:"givenName,omitempty"`
13+
MiddleName string `json:"middleName,omitempty"`
14+
}
15+
16+
func (n Name) IsZero() bool {
17+
return n == Name{}
18+
}
19+
820
// User is the core User resource defined in RFC 7643, Section 4.1.
921
type User struct {
1022
Schemas []SchemaURI `json:"schemas"`
1123
ID string `json:"id"`
1224
UserName string `json:"userName"`
25+
Name *Name `json:"name,omitempty"`
1326
Emails []Email `json:"emails,omitempty"`
1427
Meta Meta `json:"meta"`
1528
}

internal/api/scim/core/user_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,22 @@ func TestUser(t *testing.T) {
5151
require.NoError(t, err)
5252
require.NotContains(t, string(body), "emails")
5353
})
54+
55+
t.Run("omits the name when there is none", func(t *testing.T) {
56+
user.Name = nil
57+
58+
body, err := json.Marshal(user)
59+
60+
require.NoError(t, err)
61+
require.NotContains(t, string(body), `"name"`)
62+
})
63+
64+
t.Run("serializes only the name components that are set", func(t *testing.T) {
65+
user.Name = &Name{FamilyName: "Jensen", GivenName: "Barbara"}
66+
67+
body, err := json.Marshal(user)
68+
69+
require.NoError(t, err)
70+
require.Contains(t, string(body), `"name":{"familyName":"Jensen","givenName":"Barbara"}`)
71+
})
5472
}

internal/api/scim/mapper.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,51 @@ func NewUserMapper(baseURL string) UserMapper {
1717
return UserMapper{baseURL: baseURL}
1818
}
1919

20-
func (m UserMapper) MapFrom(u *models.User) *core.User {
21-
id, email := u.ID.String(), u.GetEmail()
20+
func (m UserMapper) MapFrom(in models.ProvisionedUser) *core.User {
21+
id := in.ID.String()
2222

2323
meta := core.NewMeta(m.baseURL, core.ResourceTypeUser, core.EndpointUsers, id)
24-
meta.Created, meta.LastModified = u.CreatedAt.UTC(), u.UpdatedAt.UTC()
24+
meta.Created, meta.LastModified = in.CreatedAt.UTC(), in.UpdatedAt.UTC()
2525

2626
user := &core.User{
2727
Schemas: []core.SchemaURI{core.SchemaUser},
2828
ID: id,
29-
UserName: email,
29+
UserName: userName(in),
30+
Name: name(in),
3031
Meta: meta,
3132
}
3233

33-
if email != "" {
34-
user.Emails = []core.Email{{Value: email, Primary: true}}
34+
if address := email(in); address != "" {
35+
user.Emails = []core.Email{{Value: address, Primary: true}}
3536
}
3637

3738
return user
3839
}
40+
41+
func email(in models.ProvisionedUser) string {
42+
if email := in.Claim("email"); email != "" {
43+
return email
44+
}
45+
return in.GetEmail()
46+
}
47+
48+
func userName(in models.ProvisionedUser) string {
49+
if userName := in.Claim("preferred_username"); userName != "" {
50+
return userName
51+
}
52+
return email(in)
53+
}
54+
55+
func name(in models.ProvisionedUser) *core.Name {
56+
name := core.Name{
57+
Formatted: in.Claim("name"),
58+
FamilyName: in.Claim("family_name"),
59+
GivenName: in.Claim("given_name"),
60+
MiddleName: in.Claim("middle_name"),
61+
}
62+
63+
if name.IsZero() {
64+
return nil
65+
}
66+
return &name
67+
}

internal/api/scim/mapper_test.go

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,25 @@ func TestUserMapper(t *testing.T) {
1616
createdAt := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC)
1717
updatedAt := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC)
1818

19-
newModel := func(email string) *models.User {
20-
return &models.User{
21-
ID: id,
22-
Email: storage.NullString(email),
23-
CreatedAt: createdAt,
24-
UpdatedAt: updatedAt,
19+
newModel := func(email string, claims map[string]interface{}) models.ProvisionedUser {
20+
in := models.ProvisionedUser{
21+
User: &models.User{
22+
ID: id,
23+
Email: storage.NullString(email),
24+
CreatedAt: createdAt,
25+
UpdatedAt: updatedAt,
26+
},
2527
}
28+
if claims != nil {
29+
in.Identity = &models.Identity{IdentityData: claims}
30+
}
31+
return in
2632
}
2733

2834
mapper := NewUserMapper("http://localhost:9999/scim/v2")
2935

3036
t.Run("maps a user onto the core User resource", func(t *testing.T) {
31-
user := mapper.MapFrom(newModel("bjensen@example.com"))
37+
user := mapper.MapFrom(newModel("bjensen@example.com", nil))
3238

3339
require.Equal(t, []core.SchemaURI{core.SchemaUser}, user.Schemas)
3440
require.Equal(t, id.String(), user.ID)
@@ -38,20 +44,20 @@ func TestUserMapper(t *testing.T) {
3844
})
3945

4046
t.Run("omits emails when the user has no email", func(t *testing.T) {
41-
user := mapper.MapFrom(newModel(""))
47+
user := mapper.MapFrom(newModel("", nil))
4248

4349
require.Empty(t, user.UserName)
4450
require.Nil(t, user.Emails)
4551
})
4652

4753
t.Run("builds the location from the base URL", func(t *testing.T) {
48-
user := NewUserMapper("https://auth.example.com/scim/v2").MapFrom(newModel("bjensen@example.com"))
54+
user := NewUserMapper("https://auth.example.com/scim/v2").MapFrom(newModel("bjensen@example.com", nil))
4955

5056
require.Equal(t, "https://auth.example.com/scim/v2/Users/"+id.String(), user.Meta.Location)
5157
})
5258

5359
t.Run("normalizes the timestamps to UTC", func(t *testing.T) {
54-
model := newModel("bjensen@example.com")
60+
model := newModel("bjensen@example.com", nil)
5561
model.CreatedAt = createdAt.In(time.FixedZone("MDT", -6*60*60))
5662

5763
user := mapper.MapFrom(model)
@@ -61,7 +67,69 @@ func TestUserMapper(t *testing.T) {
6167
require.Equal(t, updatedAt, user.Meta.LastModified)
6268
})
6369

70+
t.Run("prefers the email the provider supplied over the user record", func(t *testing.T) {
71+
user := mapper.MapFrom(newModel("stale@example.com", map[string]interface{}{
72+
"email": "bjensen@example.com",
73+
}))
74+
75+
require.Equal(t, "bjensen@example.com", user.UserName)
76+
require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails)
77+
})
78+
79+
t.Run("falls back to the user record when the provider supplied no email", func(t *testing.T) {
80+
user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{
81+
"sub": id.String(),
82+
}))
83+
84+
require.Equal(t, "bjensen@example.com", user.UserName)
85+
require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails)
86+
})
87+
88+
t.Run("prefers preferred_username for the userName", func(t *testing.T) {
89+
user := mapper.MapFrom(newModel("", map[string]interface{}{
90+
"preferred_username": "bjensen",
91+
"email": "bjensen@example.com",
92+
}))
93+
94+
require.Equal(t, "bjensen", user.UserName)
95+
require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails)
96+
})
97+
98+
t.Run("maps the name components the provider supplied", func(t *testing.T) {
99+
user := mapper.MapFrom(newModel("", map[string]interface{}{
100+
"name": "Ms. Barbara Jane Jensen, III",
101+
"family_name": "Jensen",
102+
"given_name": "Barbara",
103+
"middle_name": "Jane",
104+
}))
105+
106+
require.Equal(t, &core.Name{
107+
Formatted: "Ms. Barbara Jane Jensen, III",
108+
FamilyName: "Jensen",
109+
GivenName: "Barbara",
110+
MiddleName: "Jane",
111+
}, user.Name)
112+
})
113+
114+
t.Run("omits the name when the provider supplied no components", func(t *testing.T) {
115+
require.Nil(t, mapper.MapFrom(newModel("bjensen@example.com", nil)).Name)
116+
require.Nil(t, mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{
117+
"sub": id.String(),
118+
})).Name)
119+
})
120+
121+
t.Run("ignores claims that are not strings", func(t *testing.T) {
122+
user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{
123+
"email": 12345,
124+
"given_name": []string{"Barbara"},
125+
"family_name": "Jensen",
126+
}))
127+
128+
require.Equal(t, "bjensen@example.com", user.UserName)
129+
require.Equal(t, &core.Name{FamilyName: "Jensen"}, user.Name)
130+
})
131+
64132
t.Run("satisfies the Mapper interface", func(t *testing.T) {
65-
var _ Mapper[*models.User, *core.User] = NewUserMapper("")
133+
var _ Mapper[models.ProvisionedUser, *core.User] = NewUserMapper("")
66134
})
67135
}

internal/api/scim/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type TokenExtractor func(r *http.Request) (string, error)
1919
type Server struct {
2020
db *storage.Connection
2121
extract TokenExtractor
22-
users Mapper[*models.User, *core.User]
22+
users Mapper[models.ProvisionedUser, *core.User]
2323
serviceProviderConfig *core.ServiceProviderConfig
2424
}
2525

internal/api/scim/users.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func (srv *Server) UserByID(w http.ResponseWriter, r *http.Request) error {
1919
}
2020

2121
provider := shared.GetSSOProvider(ctx)
22-
user, err := models.FindUserByIDAndSSOProviderID(srv.db.WithContext(ctx), id, provider.ID)
22+
user, err := provider.FindProvisionedUserByID(srv.db.WithContext(ctx), id)
2323
if err != nil {
2424
if models.IsNotFoundError(err) {
2525
return userNotFound(w)

internal/api/scim_test.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ type scimTenant struct {
139139
token string
140140
}
141141

142-
func seedSCIMTenant(t *testing.T, conn *storage.Connection, token, email string) *scimTenant {
142+
func seedSCIMTenant(t *testing.T, conn *storage.Connection, token, email string, extraClaims ...map[string]interface{}) *scimTenant {
143143
t.Helper()
144144

145145
id := uuid.Must(uuid.NewV4()).String()
@@ -160,10 +160,17 @@ func seedSCIMTenant(t *testing.T, conn *storage.Connection, token, email string)
160160
user.IsSSOUser = true
161161
require.NoError(t, conn.Create(user))
162162

163-
identity, err := models.NewIdentity(user, "sso:"+provider.ID.String(), map[string]interface{}{
163+
claims := map[string]interface{}{
164164
"sub": user.ID.String(),
165165
"email": email,
166-
})
166+
}
167+
for _, extra := range extraClaims {
168+
for key, value := range extra {
169+
claims[key] = value
170+
}
171+
}
172+
173+
identity, err := models.NewIdentity(user, provider.ProviderType(), claims)
167174
require.NoError(t, err)
168175
require.NoError(t, conn.Create(identity))
169176

@@ -225,6 +232,43 @@ func TestSCIMUsers(t *testing.T) {
225232
require.Equal(t, http.StatusOK, get(b.user.ID.String(), b.token).Code)
226233
})
227234

235+
t.Run("maps the attributes the provider supplied", func(t *testing.T) {
236+
c := seedSCIMTenant(t, conn, "scim_token_c", "stale@example.com", map[string]interface{}{
237+
"email": "bjensen@example.com",
238+
"preferred_username": "bjensen",
239+
"name": "Ms. Barbara Jane Jensen, III",
240+
"family_name": "Jensen",
241+
"given_name": "Barbara",
242+
})
243+
244+
w := get(c.user.ID.String(), c.token)
245+
246+
require.Equal(t, http.StatusOK, w.Code)
247+
require.JSONEq(t, fmt.Sprintf(`{
248+
"schemas": [%q],
249+
"id": %q,
250+
"userName": "bjensen",
251+
"name": {
252+
"formatted": "Ms. Barbara Jane Jensen, III",
253+
"familyName": "Jensen",
254+
"givenName": "Barbara"
255+
},
256+
"emails": [{"value": "bjensen@example.com", "primary": true}],
257+
"meta": {
258+
"resourceType": "User",
259+
"created": %q,
260+
"lastModified": %q,
261+
"location": "%s/scim/v2/Users/%s"
262+
}
263+
}`,
264+
scimCore.SchemaUser,
265+
c.user.ID,
266+
c.user.CreatedAt.UTC().Format(time.RFC3339Nano),
267+
c.user.UpdatedAt.UTC().Format(time.RFC3339Nano),
268+
config.API.ExternalURL, c.user.ID,
269+
), w.Body.String())
270+
})
271+
228272
t.Run("hides a user belonging to another provider", func(t *testing.T) {
229273
w := get(b.user.ID.String(), a.token)
230274

internal/models/identity.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ func FindIdentityByIdAndProvider(tx *storage.Connection, providerId, provider st
9393
return identity, nil
9494
}
9595

96+
// FindIdentityByUserIDAndProvider searches for the identity linking a user to a provider.
97+
func FindIdentityByUserIDAndProvider(tx *storage.Connection, userID uuid.UUID, provider string) (*Identity, error) {
98+
identity := &Identity{}
99+
if err := tx.Q().Where("user_id = ? AND provider = ?", userID, provider).First(identity); err != nil {
100+
if errors.Cause(err) == sql.ErrNoRows {
101+
return nil, IdentityNotFoundError{}
102+
}
103+
return nil, errors.Wrap(err, "error finding identity")
104+
}
105+
return identity, nil
106+
}
107+
96108
// FindIdentitiesByUserID returns all identities associated to a user ID.
97109
func FindIdentitiesByUserID(tx *storage.Connection, userID uuid.UUID) ([]*Identity, error) {
98110
identities := []*Identity{}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package models
2+
3+
// ProvisionedUser is a user as seen through one SSO provider. The identity
4+
// carries the claims that provider supplied, so attributes read from it are
5+
// scoped to that provider rather than to the user record, which is shared
6+
// across every provider the user is linked to.
7+
type ProvisionedUser struct {
8+
*User
9+
Identity *Identity
10+
}
11+
12+
// Claim returns the string claim the provider supplied under key, or an empty
13+
// string when it is absent or not a string.
14+
func (p ProvisionedUser) Claim(key string) string {
15+
if p.Identity == nil {
16+
return ""
17+
}
18+
value, _ := p.Identity.IdentityData[key].(string)
19+
return value
20+
}

0 commit comments

Comments
 (0)