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-13 13:40:14 +02:00
committed by Ralf Haferkamp
parent b9dc2baaed
commit 6059747491
7 changed files with 123 additions and 75 deletions

View File

@@ -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

View File

@@ -50,10 +50,14 @@ role_assignment:
oidc_role_mapper:
role_claim: ocisRoles
role_mapping:
admin: myAdminRole
user: myUserRole
spaceadmin: mySpaceAdminRole
guest: myGuestRole
- role_name: admin
claim_value: myAdminRole
- role_name: spaceadmin
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`.
@@ -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.
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
will be logged and the user will not be able to login.
`role_mapping` and a user's claim values result in multiple possible roles for a user, the order in
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
admin: ocisAdmin
user: ocisUser
spaceadmin: ocisSpaceAdmin
guest: ocisGuest
- role_name: admin
claim_value: ocisAdmin
- role_name: spaceadmin
claim_value: ocisSpaceAdmin
- role_name: user
claim_value: ocisUser
- role_name: guest:
claim_value: ocisGuest
```
## Recommendations for Production Deployments

View File

@@ -178,7 +178,7 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config)
userroles.WithRoleService(rolesClient),
userroles.WithLogger(logger),
userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim),
userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RoleMapping),
userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RolesMap),
userroles.WithAutoProvisonCreator(autoProvsionCreator),
)
default:

View File

@@ -135,8 +135,14 @@ type RoleAssignment struct {
// OIDCRoleMapper contains the configuration for the "oidc" role assignment driber
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."`
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."`
RoleClaim string `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment."`
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

View File

@@ -60,11 +60,11 @@ func DefaultConfig() *config.Config {
// this default is only relevant when Driver is set to "oidc"
OIDCRoleMapper: config.OIDCRoleMapper{
RoleClaim: "roles",
RoleMapping: map[string]string{
"admin": "ocisAdmin",
"spaceadmin": "ocisSpaceAdmin",
"user": "ocisUser",
"guest": "ocisGuest",
RolesMap: []config.RoleMapping{
config.RoleMapping{RoleName: "admin", ClaimValue: "ocisAdmin"},
config.RoleMapping{RoleName: "spaceadmin", ClaimValue: "ocisSpaceAdmin"},
config.RoleMapping{RoleName: "user", ClaimValue: "ocisUser"},
config.RoleMapping{RoleName: "guest", ClaimValue: "ocisGuest"},
},
},
},

View File

@@ -34,47 +34,55 @@ func NewOIDCRoleAssigner(opts ...Option) UserRoleAssigner {
// already has a different role assigned.
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()
claimValueToRoleID, err := ra.oidcClaimvaluesToRoleIDs()
roleNamesToRoleIDs, err := ra.roleNamesToRoleIDs()
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
}
roleIDsFromClaim := make([]string, 0, 1)
claimRoles, ok := claims[ra.rolesClaim].([]interface{})
claimRolesRaw, ok := claims[ra.rolesClaim].([]interface{})
if !ok {
logger.Error().Err(err).Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims")
return nil, err
logger.Error().Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims")
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")
for _, cri := range claimRoles {
claimRoles := map[string]struct{}{}
for _, cri := range claimRolesRaw {
cr, ok := cri.(string)
if !ok {
err := errors.New("invalid role in claims")
logger.Error().Err(err).Interface("claimValue", cri).Msg("Is not a valid string.")
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) {
default:
err := errors.New("too many roles found in claims")
logger.Error().Err(err).Msg("Only one role per user is allowed.")
return nil, err
case 0:
err := errors.New("no role in claim, maps to a ocis role")
claimRoles[cr] = struct{}{}
}
if len(claimRoles) == 0 {
err := errors.New("no roles set in claim")
logger.Error().Err(err).Msg("")
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)
if err != nil {
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
}
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()
if err != nil {
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{
AccountUuid: user.GetId().GetOpaqueId(),
RoleId: roleIDsFromClaim[0],
RoleId: roleIDFromClaim,
}); err != nil {
logger.Error().Err(err).Msg("Role assignment failed")
return nil, err
}
}
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDsFromClaim)
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", []string{roleIDFromClaim})
return user, nil
}
@@ -136,32 +145,32 @@ func (ra oidcRoleAssigner) prepareAdminContext() (context.Context, error) {
return newctx, nil
}
type roleClaimToIDCache struct {
roleClaimToID map[string]string
lastRead time.Time
lock sync.RWMutex
type roleNameToIDCache struct {
roleNameToID map[string]string
lastRead time.Time
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
roleClaimToID.lock.RLock()
roleNameToID.lock.RLock()
if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL {
defer roleClaimToID.lock.RUnlock()
return roleClaimToID.roleClaimToID, nil
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
defer roleNameToID.lock.RUnlock()
return roleNameToID.roleNameToID, nil
}
ra.logger.Debug().Msg("refreshing roles ids")
// cache needs Refresh get a write lock
roleClaimToID.lock.RUnlock()
roleClaimToID.lock.Lock()
defer roleClaimToID.lock.Unlock()
roleNameToID.lock.RUnlock()
roleNameToID.lock.Lock()
defer roleNameToID.lock.Unlock()
// check again, another goroutine might have updated while we "upgraded" the lock
if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL {
return roleClaimToID.roleClaimToID, nil
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
return roleNameToID.roleNameToID, nil
}
// Get all roles to find the role IDs.
@@ -183,16 +192,10 @@ func (ra oidcRoleAssigner) oidcClaimvaluesToRoleIDs() (map[string]string, error)
newIDs := map[string]string{}
for _, role := range res.Bundles {
ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role")
roleClaim, ok := ra.roleMapping[role.Name]
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
newIDs[role.Name] = role.Id
}
ra.logger.Debug().Interface("roleMap", newIDs).Msg("Claim Role to role ID map")
roleClaimToID.roleClaimToID = newIDs
roleClaimToID.lastRead = time.Now()
return roleClaimToID.roleClaimToID, nil
ra.logger.Debug().Interface("roleMap", newIDs).Msg("Role Name to role ID map")
roleNameToID.roleNameToID = newIDs
roleNameToID.lastRead = time.Now()
return roleNameToID.roleNameToID, nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
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/config"
)
//go:generate mockery --name=UserRoleAssigner
@@ -26,7 +27,7 @@ type UserRoleAssigner interface {
type Options struct {
roleService settingssvc.RoleService
rolesClaim string
roleMapping map[string]string
roleMapping []config.RoleMapping
autoProvsionCreator autoprovision.Creator
logger log.Logger
}
@@ -56,7 +57,7 @@ func WithRolesClaim(claim string) Option {
}
// 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) {
o.roleMapping = roleMap
}