proxy: Rework oidc role mapper to allow multiple matching roles

If multiple claims values have a valid matching for ocis roles, we'll pick
the ocis role that appears first in the mapping configuration.
This commit is contained in:
Ralf Haferkamp
2023-04-19 08:56:47 +02:00
committed by Ralf Haferkamp
parent b9dc2baaed
commit 6059747491
7 changed files with 123 additions and 75 deletions
@@ -0,0 +1,23 @@
Enhancement: Added possiblity to assign roles based on OIDC claims
oCIS can now be configured to update a user's role assignment from the values of a claim provided
via the IDPs userinfo endpoint. The claim name and the mapping between claim values and ocis role
name can be configured via the configuration of the proxy service. Example:
```yaml
role_assignment:
driver: oidc
oidc_role_mapper:
role_claim: ocisRoles
role_mapping:
- role_name: admin
claim_value: myAdminRole
- role_name: spaceadmin
claim_value: mySpaceAdminRole
- role_name: user
claim_value: myUserRole
- role_name: guest:
claim_value: myGuestRole
```
https://github.com/owncloud/ocis/pull/6048
+26 -11
View File
@@ -50,10 +50,14 @@ role_assignment:
oidc_role_mapper: oidc_role_mapper:
role_claim: ocisRoles role_claim: ocisRoles
role_mapping: role_mapping:
admin: myAdminRole - role_name: admin
user: myUserRole claim_value: myAdminRole
spaceadmin: mySpaceAdminRole - role_name: spaceadmin
guest: myGuestRole claim_value: mySpaceAdminRole
- role_name: user
claim_value: myUserRole
- role_name: guest:
claim_value: myGuestRole
``` ```
This would assign the role `admin` to users with the value `myAdminRole` in the claim `ocisRoles`. This would assign the role `admin` to users with the value `myAdminRole` in the claim `ocisRoles`.
@@ -62,16 +66,27 @@ The role `user` to users with the values `myUserRole` in the claims `ocisRoles`
Claim values that are not mapped to a specific ownCloud Infinite Scale role will be ignored. Claim values that are not mapped to a specific ownCloud Infinite Scale role will be ignored.
Note: An ownCloud Infinite Scale user can only have a single role assigned. If the configured Note: An ownCloud Infinite Scale user can only have a single role assigned. If the configured
`role_mapping` and a user's claim values result in multiple possible roles for a user, an error `role_mapping` and a user's claim values result in multiple possible roles for a user, the order in
will be logged and the user will not be able to login. which the role mappings are defined in the configuration is important. The first role in the
`role_mappings` where the `claim_value` matches a value from the user's roles claim will be assigned
to the user. So if e.g. a user's `ocisRoles` claim has the values `myUserRole` and
`mySpaceAdminRole` that user will get the ocis role `spaceadmin` assigned (because `spaceadmin`
appears before `user` in the above sample configuration).
The default `role_claim` (or `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`) is `roles`. The `role_mapping` is: If a user's claim values don't match any of the configured role mappings an error will be logged and
the user will not be able to login.
The default `role_claim` (or `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`) is `roles`. The default `role_mapping` is:
```yaml ```yaml
admin: ocisAdmin - role_name: admin
user: ocisUser claim_value: ocisAdmin
spaceadmin: ocisSpaceAdmin - role_name: spaceadmin
guest: ocisGuest claim_value: ocisSpaceAdmin
- role_name: user
claim_value: ocisUser
- role_name: guest:
claim_value: ocisGuest
``` ```
## Recommendations for Production Deployments ## Recommendations for Production Deployments
+1 -1
View File
@@ -178,7 +178,7 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config)
userroles.WithRoleService(rolesClient), userroles.WithRoleService(rolesClient),
userroles.WithLogger(logger), userroles.WithLogger(logger),
userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim), userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim),
userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RoleMapping), userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RolesMap),
userroles.WithAutoProvisonCreator(autoProvsionCreator), userroles.WithAutoProvisonCreator(autoProvsionCreator),
) )
default: default:
+8 -2
View File
@@ -135,8 +135,14 @@ type RoleAssignment struct {
// OIDCRoleMapper contains the configuration for the "oidc" role assignment driber // OIDCRoleMapper contains the configuration for the "oidc" role assignment driber
type OIDCRoleMapper struct { type OIDCRoleMapper struct {
RoleClaim string `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment."` RoleClaim string `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment."`
RoleMapping map[string]string `yaml:"role_mapping" desc:"A mapping of ocis role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."` RolesMap []RoleMapping `yaml:"role_mapping" desc:"A list of mappings of ocis role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."`
}
// RoleMapping defines which ocis role matches a specific claim value
type RoleMapping struct {
RoleName string `yaml:"role_name" desc:"The name of an ocis role that this mapping should apply for."`
ClaimValue string `yaml:"claim_value" desc:"The value of the 'PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM' that matches the role defined in 'role_name'."`
} }
// PolicySelector is the toplevel-configuration for different selectors // PolicySelector is the toplevel-configuration for different selectors
@@ -60,11 +60,11 @@ func DefaultConfig() *config.Config {
// this default is only relevant when Driver is set to "oidc" // this default is only relevant when Driver is set to "oidc"
OIDCRoleMapper: config.OIDCRoleMapper{ OIDCRoleMapper: config.OIDCRoleMapper{
RoleClaim: "roles", RoleClaim: "roles",
RoleMapping: map[string]string{ RolesMap: []config.RoleMapping{
"admin": "ocisAdmin", config.RoleMapping{RoleName: "admin", ClaimValue: "ocisAdmin"},
"spaceadmin": "ocisSpaceAdmin", config.RoleMapping{RoleName: "spaceadmin", ClaimValue: "ocisSpaceAdmin"},
"user": "ocisUser", config.RoleMapping{RoleName: "user", ClaimValue: "ocisUser"},
"guest": "ocisGuest", config.RoleMapping{RoleName: "guest", ClaimValue: "ocisGuest"},
}, },
}, },
}, },
+57 -54
View File
@@ -34,47 +34,55 @@ func NewOIDCRoleAssigner(opts ...Option) UserRoleAssigner {
// already has a different role assigned. // already has a different role assigned.
func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]interface{}) (*cs3.User, error) { func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]interface{}) (*cs3.User, error) {
logger := ra.logger.SubloggerWithRequestID(ctx).With().Str("userid", user.GetId().GetOpaqueId()).Logger() logger := ra.logger.SubloggerWithRequestID(ctx).With().Str("userid", user.GetId().GetOpaqueId()).Logger()
claimValueToRoleID, err := ra.oidcClaimvaluesToRoleIDs() roleNamesToRoleIDs, err := ra.roleNamesToRoleIDs()
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error mapping claims to roles ids") logger.Error().Err(err).Msg("Error mapping role names to role ids")
return nil, err return nil, err
} }
roleIDsFromClaim := make([]string, 0, 1) claimRolesRaw, ok := claims[ra.rolesClaim].([]interface{})
claimRoles, ok := claims[ra.rolesClaim].([]interface{})
if !ok { if !ok {
logger.Error().Err(err).Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims") logger.Error().Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims")
return nil, err return nil, errors.New("no roles in user claims")
} }
logger.Debug().Str("rolesClaim", ra.rolesClaim).Interface("rolesInClaim", claims[ra.rolesClaim]).Msg("got roles in claim") logger.Debug().Str("rolesClaim", ra.rolesClaim).Interface("rolesInClaim", claims[ra.rolesClaim]).Msg("got roles in claim")
for _, cri := range claimRoles { claimRoles := map[string]struct{}{}
for _, cri := range claimRolesRaw {
cr, ok := cri.(string) cr, ok := cri.(string)
if !ok { if !ok {
err := errors.New("invalid role in claims") err := errors.New("invalid role in claims")
logger.Error().Err(err).Interface("claimValue", cri).Msg("Is not a valid string.") logger.Error().Err(err).Interface("claimValue", cri).Msg("Is not a valid string.")
return nil, err return nil, err
} }
id, ok := claimValueToRoleID[cr]
if !ok {
logger.Debug().Str("role", cr).Msg("No mapping for claim role. Skipped.")
continue
}
roleIDsFromClaim = append(roleIDsFromClaim, id)
}
logger.Debug().Interface("roleIDs", roleIDsFromClaim).Msg("Mapped claim roles to roleids")
switch len(roleIDsFromClaim) { claimRoles[cr] = struct{}{}
default: }
err := errors.New("too many roles found in claims")
logger.Error().Err(err).Msg("Only one role per user is allowed.") if len(claimRoles) == 0 {
return nil, err err := errors.New("no roles set in claim")
case 0:
err := errors.New("no role in claim, maps to a ocis role")
logger.Error().Err(err).Msg("") logger.Error().Err(err).Msg("")
return nil, err return nil, err
case 1:
// exactly one mapping. This is right
} }
// the roleMapping config is supposed to have the role mappings ordered from the highest privileged role
// down to the lowest privileged role. Since ocis currently only can handle a single role assignment we
// pick the highest privileged role that matches a value from the claims
roleIDFromClaim := ""
for _, mapping := range ra.Options.roleMapping {
if _, ok := claimRoles[mapping.ClaimValue]; ok {
logger.Debug().Str("ocisRole", mapping.RoleName).Str("role id", roleNamesToRoleIDs[mapping.RoleName]).Msg("first matching role")
roleIDFromClaim = roleNamesToRoleIDs[mapping.RoleName]
break
}
}
if roleIDFromClaim == "" {
err := errors.New("no role in claim maps to an ocis role")
logger.Error().Err(err).Msg("")
return nil, err
}
assignedRoles, err := loadRolesIDs(ctx, user.GetId().GetOpaqueId(), ra.roleService) assignedRoles, err := loadRolesIDs(ctx, user.GetId().GetOpaqueId(), ra.roleService)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Could not load roles") logger.Error().Err(err).Msg("Could not load roles")
@@ -86,8 +94,9 @@ func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *c
return nil, err return nil, err
} }
logger.Debug().Interface("assignedRoleIds", assignedRoles).Msg("Currently assigned roles") logger.Debug().Interface("assignedRoleIds", assignedRoles).Msg("Currently assigned roles")
if len(assignedRoles) == 0 || (assignedRoles[0] != roleIDsFromClaim[0]) {
logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleIds", roleIDsFromClaim).Msg("Updating role assignment for user") if len(assignedRoles) == 0 || (assignedRoles[0] != roleIDFromClaim) {
logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleId", roleIDFromClaim).Msg("Updating role assignment for user")
newctx, err := ra.prepareAdminContext() newctx, err := ra.prepareAdminContext()
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error creating admin context") logger.Error().Err(err).Msg("Error creating admin context")
@@ -95,14 +104,14 @@ func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *c
} }
if _, err = ra.roleService.AssignRoleToUser(newctx, &settingssvc.AssignRoleToUserRequest{ if _, err = ra.roleService.AssignRoleToUser(newctx, &settingssvc.AssignRoleToUserRequest{
AccountUuid: user.GetId().GetOpaqueId(), AccountUuid: user.GetId().GetOpaqueId(),
RoleId: roleIDsFromClaim[0], RoleId: roleIDFromClaim,
}); err != nil { }); err != nil {
logger.Error().Err(err).Msg("Role assignment failed") logger.Error().Err(err).Msg("Role assignment failed")
return nil, err return nil, err
} }
} }
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDsFromClaim) user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", []string{roleIDFromClaim})
return user, nil return user, nil
} }
@@ -136,32 +145,32 @@ func (ra oidcRoleAssigner) prepareAdminContext() (context.Context, error) {
return newctx, nil return newctx, nil
} }
type roleClaimToIDCache struct { type roleNameToIDCache struct {
roleClaimToID map[string]string roleNameToID map[string]string
lastRead time.Time lastRead time.Time
lock sync.RWMutex lock sync.RWMutex
} }
var roleClaimToID roleClaimToIDCache var roleNameToID roleNameToIDCache
func (ra oidcRoleAssigner) oidcClaimvaluesToRoleIDs() (map[string]string, error) { func (ra oidcRoleAssigner) roleNamesToRoleIDs() (map[string]string, error) {
cacheTTL := 5 * time.Minute cacheTTL := 5 * time.Minute
roleClaimToID.lock.RLock() roleNameToID.lock.RLock()
if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL { if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
defer roleClaimToID.lock.RUnlock() defer roleNameToID.lock.RUnlock()
return roleClaimToID.roleClaimToID, nil return roleNameToID.roleNameToID, nil
} }
ra.logger.Debug().Msg("refreshing roles ids") ra.logger.Debug().Msg("refreshing roles ids")
// cache needs Refresh get a write lock // cache needs Refresh get a write lock
roleClaimToID.lock.RUnlock() roleNameToID.lock.RUnlock()
roleClaimToID.lock.Lock() roleNameToID.lock.Lock()
defer roleClaimToID.lock.Unlock() defer roleNameToID.lock.Unlock()
// check again, another goroutine might have updated while we "upgraded" the lock // check again, another goroutine might have updated while we "upgraded" the lock
if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL { if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
return roleClaimToID.roleClaimToID, nil return roleNameToID.roleNameToID, nil
} }
// Get all roles to find the role IDs. // Get all roles to find the role IDs.
@@ -183,16 +192,10 @@ func (ra oidcRoleAssigner) oidcClaimvaluesToRoleIDs() (map[string]string, error)
newIDs := map[string]string{} newIDs := map[string]string{}
for _, role := range res.Bundles { for _, role := range res.Bundles {
ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role") ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role")
roleClaim, ok := ra.roleMapping[role.Name] newIDs[role.Name] = role.Id
if !ok {
err := errors.New("Incomplete role mapping")
ra.logger.Error().Err(err).Str("role", role.Name).Msg("Role not mapped to a claim value")
return map[string]string{}, err
}
newIDs[roleClaim] = role.Id
} }
ra.logger.Debug().Interface("roleMap", newIDs).Msg("Claim Role to role ID map") ra.logger.Debug().Interface("roleMap", newIDs).Msg("Role Name to role ID map")
roleClaimToID.roleClaimToID = newIDs roleNameToID.roleNameToID = newIDs
roleClaimToID.lastRead = time.Now() roleNameToID.lastRead = time.Now()
return roleClaimToID.roleClaimToID, nil return roleNameToID.roleNameToID, nil
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/log"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
"github.com/owncloud/ocis/v2/services/proxy/pkg/autoprovision" "github.com/owncloud/ocis/v2/services/proxy/pkg/autoprovision"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
) )
//go:generate mockery --name=UserRoleAssigner //go:generate mockery --name=UserRoleAssigner
@@ -26,7 +27,7 @@ type UserRoleAssigner interface {
type Options struct { type Options struct {
roleService settingssvc.RoleService roleService settingssvc.RoleService
rolesClaim string rolesClaim string
roleMapping map[string]string roleMapping []config.RoleMapping
autoProvsionCreator autoprovision.Creator autoProvsionCreator autoprovision.Creator
logger log.Logger logger log.Logger
} }
@@ -56,7 +57,7 @@ func WithRolesClaim(claim string) Option {
} }
// WithRoleMapping configures the map of ocis role names to claims values // WithRoleMapping configures the map of ocis role names to claims values
func WithRoleMapping(roleMap map[string]string) Option { func WithRoleMapping(roleMap []config.RoleMapping) Option {
return func(o *Options) { return func(o *Options) {
o.roleMapping = roleMap o.roleMapping = roleMap
} }