Skip to content
Merged
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
19 changes: 19 additions & 0 deletions internal/storetest/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,25 @@ func runIdentitySuite(t *testing.T, newStore func(t *testing.T) Store) {
assert.Equal(t, []string{testAction}, actions)
})

t.Run("provision identity tolerates duplicate initial role IDs", func(t *testing.T) {
store := newStore(t)
createRole(t, store, testRoleID)
require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{
RoleID: testRoleID,
Action: testAction,
}))
req := provisionRequest()
req.InitialRoleIDs = []string{testRoleID, testRoleID}

result, err := store.ProvisionIdentity(context.Background(), req)
require.NoError(t, err)
require.True(t, result.Created)

actions, err := store.ResolvePrincipalActions(context.Background(), result.Principal.ID)
require.NoError(t, err)
assert.Equal(t, []string{testAction}, actions)
})

t.Run("provision identity fails when initial role is missing", func(t *testing.T) {
store := newStore(t)
req := provisionRequest()
Expand Down
42 changes: 42 additions & 0 deletions internal/storetest/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,46 @@ func runProvisioningRuleSuite(t *testing.T, newStore func(t *testing.T) Store) {
})
}
})

t.Run("provisioning rules deduplicate AssignRoleIDs on create", func(t *testing.T) {
store := newStore(t)
createRole(t, store, testRoleID)
trustProvider(t, store, providerFixture())
req := provisioningRuleRequest()
req.AssignRoleIDs = []string{testRoleID, testRoleID}

rule, err := store.CreateProvisioningRule(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, []string{testRoleID}, rule.AssignRoleIDs)

found, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID)
require.NoError(t, err)
assert.Equal(t, []string{testRoleID}, found.AssignRoleIDs)
})

t.Run("provisioning rules deduplicate AssignRoleIDs on update", func(t *testing.T) {
store := newStore(t)
createRole(t, store, testRoleID)
createRole(t, store, "notes-writer")
trustProvider(t, store, providerFixture())
_, err := store.CreateProvisioningRule(context.Background(), provisioningRuleRequest())
require.NoError(t, err)

updated := authkit.UpdateProvisioningRuleRequest{
ID: testProvisioningRuleID,
DisplayName: "Engineering readers",
Provider: providerFixture().Issuer,
Condition: `hasAny(claims.groups, ["/engineering"])`,
AssignRoleIDs: []string{"notes-writer", "notes-writer", testRoleID},
Enabled: true,
}

rule, err := store.UpdateProvisioningRule(context.Background(), updated)
require.NoError(t, err)
assert.Equal(t, []string{"notes-writer", testRoleID}, rule.AssignRoleIDs)

found, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"notes-writer", testRoleID}, found.AssignRoleIDs)
})
}
25 changes: 17 additions & 8 deletions store/memory/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,35 +176,44 @@ func (s *Store) validateInitialRolesLocked(roleIDs []string) error {
return nil
}

// provisioningRuleFromCreate converts a create request into a ProvisioningRule
// with the condition normalized and the role-ID slice copied defensively.
// provisioningRuleFromCreate converts a create request into a normalized
// ProvisioningRule (condition normalized, role IDs deduplicated and copied).
func provisioningRuleFromCreate(req authkit.CreateProvisioningRuleRequest) authkit.ProvisioningRule {
return authkit.ProvisioningRule{
return normalizeProvisioningRule(authkit.ProvisioningRule{
ID: req.ID,
DisplayName: req.DisplayName,
Provider: req.Provider,
Condition: provisioning.NormalizeCondition(req.Condition),
AssignRoleIDs: cloneStrings(req.AssignRoleIDs),
Enabled: req.Enabled,
}
})
}

// provisioningRuleFromUpdate converts an update request into a ProvisioningRule
// with the condition normalized and the role-ID slice copied defensively.
// provisioningRuleFromUpdate converts an update request into a normalized
// ProvisioningRule using the same normalization rules as
// provisioningRuleFromCreate.
func provisioningRuleFromUpdate(req authkit.UpdateProvisioningRuleRequest) authkit.ProvisioningRule {
return authkit.ProvisioningRule{
return normalizeProvisioningRule(authkit.ProvisioningRule{
ID: req.ID,
DisplayName: req.DisplayName,
Provider: req.Provider,
Condition: provisioning.NormalizeCondition(req.Condition),
AssignRoleIDs: cloneStrings(req.AssignRoleIDs),
Enabled: req.Enabled,
}
})
}

// normalizeProvisioningRule deduplicates rule.AssignRoleIDs.
func normalizeProvisioningRule(rule authkit.ProvisioningRule) authkit.ProvisioningRule {
rule.AssignRoleIDs = uniqueStrings(rule.AssignRoleIDs)

return rule
}

// assignInitialRoles adds every roleID in roleIDs to principalID's role set.
// No-op when roleIDs is empty. Callers must hold s.mu.
func assignInitialRoles(principalRoles map[string]map[string]struct{}, principalID string, roleIDs []string) {
roleIDs = uniqueStrings(roleIDs)
if len(roleIDs) == 0 {
return
}
Expand Down
21 changes: 21 additions & 0 deletions store/memory/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,24 @@ func validateRequiredStrings(name string, values []string) error {

return validateNonEmptyStrings(name, values)
}

// uniqueStrings returns values with duplicates removed, preserving the order
// of first occurrence. A nil or empty input returns nil.
func uniqueStrings(values []string) []string {
if len(values) == 0 {
return nil
}

unique := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}

seen[value] = struct{}{}
unique = append(unique, value)
}

return unique
}