feat(proxy): claim managed spaces

Signed-off-by: Julian Koberg <jkoberg@MBP-Julian-Koberg.local>
This commit is contained in:
Julian Koberg
2025-04-30 14:27:50 +02:00
parent 2868947e63
commit 02072c37c4
7 changed files with 474 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
Enhancement: Claim managed spaces
Allow managing spaces from oidc claims
https://github.com/owncloud/ocis/pull/11280

View File

@@ -0,0 +1,65 @@
package claimsmapper
import (
"regexp"
"strings"
)
// ClaimsMapper is a configurable mapper to map oidc claims to ocis spaceIDs and roles
type ClaimsMapper struct {
claimRegexp *regexp.Regexp
roleMapping map[string]string
}
// NewClaimsMapper parses the config to create a new ClaimsMapper. It expects
// - a regexp extracting the spaceID and the (unmapped) role from the claim.
// - a roleMapping string of the form "oidcRole1:manager","oidcRole2:editor","oidcRole3:viewer"
// unused roles can be omitted. Second part of the mapping must be a valid ocis role.
// can be omitted if roles already match ocis roles
//
// Panics if regexp is not compilable
func NewClaimsMapper(reg string, roleMapping []string) ClaimsMapper {
em := ClaimsMapper{
claimRegexp: regexp.MustCompile(reg),
}
if len(roleMapping) == 0 {
return em
}
em.roleMapping = make(map[string]string)
for _, ms := range roleMapping {
s := strings.Split(ms, ":")
if len(s) != 2 {
continue
}
em.roleMapping[s[0]] = s[1]
}
return em
}
// Exec extracts the spaceID and the role from a entitlement
func (em ClaimsMapper) Exec(e string) (match bool, spaceID string, role string) {
s := em.claimRegexp.FindStringSubmatch(e)
if len(s) != 3 {
return
}
spaceID = s[1]
if spaceID == "" {
return
}
role = s[2]
if em.roleMapping == nil {
match = true
return
}
role = em.roleMapping[role]
if role != "" {
match = true
return
}
return false, "", ""
}

View File

@@ -0,0 +1,76 @@
package claimsmapper
import (
"testing"
"github.com/test-go/testify/require"
)
func TestMapClaims(t *testing.T) {
type innercase struct {
input string
expectedNoMatch bool
expectedSpaceID string
expectedRole string
}
var testCases = []struct {
regexp string
mapping []string
cases []innercase
}{
{
regexp: "some-string:moreinfo:([a-zA-Z0-9-]+):and-the-role-is-(.*)",
cases: []innercase{
{
input: "some-string:moreinfo:here-is-my-uuid:and-the-role-is-here",
expectedSpaceID: "here-is-my-uuid",
expectedRole: "here",
},
{
input: "some-otherthing:moreinfo:here-is-my-uuid:and-the-role-is-not-here",
expectedNoMatch: true,
},
},
},
{
regexp: "spaceid=([a-zA-Z0-9-]+),roleid=(.*)",
mapping: []string{"overseer:manager", "worker:editor", "ghoul:viewer"},
cases: []innercase{
{
input: "spaceid=vault36,roleid=overseer",
expectedSpaceID: "vault36",
expectedRole: "manager",
},
{
input: "spaceid=vault36,roleid=worker",
expectedSpaceID: "vault36",
expectedRole: "editor",
},
{
input: "spaceid=vault36,roleid=ghoul",
expectedSpaceID: "vault36",
expectedRole: "viewer",
},
{
input: "spaceid=vault36,roleid=radroach",
expectedNoMatch: true,
},
{
input: "differentid=vault36,roleid=overseer",
expectedNoMatch: true,
},
},
},
}
for _, tc := range testCases {
cm := NewClaimsMapper(tc.regexp, tc.mapping)
for _, c := range tc.cases {
match, spaceID, role := cm.Exec(c.input)
require.Equal(t, !c.expectedNoMatch, match, c.input)
require.Equal(t, c.expectedSpaceID, spaceID, c.input)
require.Equal(t, c.expectedRole, role, c.input)
}
}
}

View File

@@ -378,11 +378,18 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.PoliciesProviderService(policiesProviderClient),
),
// finally, trigger home creation when a user logs in
// trigger home creation when a user logs in
middleware.CreateHome(
middleware.Logger(logger),
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.RoleQuotas(cfg.RoleQuotas),
),
// trigger space assignment when a user logs in
middleware.SpaceManager(
cfg.ClaimSpaceManagement,
middleware.Logger(logger),
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.ServiceAccount(cfg.ServiceAccount.ServiceAccountID, cfg.ServiceAccount.ServiceAccountSecret),
),
)
}

View File

@@ -24,27 +24,28 @@ type Config struct {
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
GrpcClient client.Client `yaml:"-"`
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
Policies []Policy `yaml:"policies"`
AdditionalPolicies []Policy `yaml:"additional_policies"`
OIDC OIDC `yaml:"oidc"`
ServiceAccount ServiceAccount `yaml:"service_account"`
RoleAssignment RoleAssignment `yaml:"role_assignment"`
PolicySelector *PolicySelector `yaml:"policy_selector"`
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"`
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"`
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"`
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"`
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"`
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"`
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the servers TLS certificate for https enabled backend services." introductionVersion:"pre5.0"`
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"`
Events Events `yaml:"events"`
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
Policies []Policy `yaml:"policies"`
AdditionalPolicies []Policy `yaml:"additional_policies"`
OIDC OIDC `yaml:"oidc"`
ServiceAccount ServiceAccount `yaml:"service_account"`
RoleAssignment RoleAssignment `yaml:"role_assignment"`
PolicySelector *PolicySelector `yaml:"policy_selector"`
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"`
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"`
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"`
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"`
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"`
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"`
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the servers TLS certificate for https enabled backend services." introductionVersion:"pre5.0"`
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"`
Events Events `yaml:"events"`
ClaimSpaceManagement ClaimSpaceManagement `yaml:"claim_space_management"`
Context context.Context `json:"-" yaml:"-"`
}
@@ -230,3 +231,11 @@ type Events struct {
AuthUsername string `yaml:"username" env:"OCIS_EVENTS_AUTH_USERNAME;PROXY_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the ocis service which receives and delivers events between the services." introductionVersion:"7.0.0"`
AuthPassword string `yaml:"password" env:"OCIS_EVENTS_AUTH_PASSWORD;PROXY_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the ocis service which receives and delivers events between the services." introductionVersion:"7.0.0"`
}
// ClaimSpaceManagement holds the configuration for claim managed spaces
type ClaimSpaceManagement struct {
Enabled bool `yaml:"enabled" env:"OCIS_CLAIMMANAGEDSPACES_ENABLED" desc:"Enables space management through OIDC claims" introductionVersion:"%%NEXT%%"`
Claim string `yaml:"claim" env:"OCIS_CLAIMMANAGEDSPACES_CLAIMNAME" desc:"The name of the claim used for space management" introductionVersion:"%%NEXT%%"`
Regexp string `yaml:"regexp" env:"OCIS_CLAIMMANAGEDSPACES_REGEXP" desc:"The regular expression that extracts spaceid and role from a claim" introductionVersion:"%%NEXT%%"`
Mapping []string `yaml:"mapping" env:"OCIS_CLAIMMANAGEDSPACES_MAPPING" desc:"(Optional) Mapping of oidc roles to ocis space roles. Example: 'oidcroleA:viewer,oidcroleB:manager'" introductionVersion:"%%NEXT%%"`
}

View File

@@ -74,6 +74,9 @@ type Options struct {
// SkipUserInfo prevents the oidc middleware from querying the userinfo endpoint and read any claims directly from the access token instead
SkipUserInfo bool
EventsPublisher events.Publisher
// Service Accounts
ServiceAccountID string
ServiceAccountSecret string
}
// newOptions initializes the available default options.
@@ -254,3 +257,11 @@ func EventsPublisher(ep events.Publisher) Option {
o.EventsPublisher = ep
}
}
// ServiceAccount sets the service account user
func ServiceAccount(id string, secret string) Option {
return func(o *Options) {
o.ServiceAccountID = id
o.ServiceAccountSecret = secret
}
}

View File

@@ -0,0 +1,279 @@
package middleware
import (
"context"
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/owncloud/ocis/v2/ocis-pkg/claimsmapper"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
"github.com/owncloud/reva/v2/pkg/conversions"
revactx "github.com/owncloud/reva/v2/pkg/ctx"
"github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool"
"github.com/owncloud/reva/v2/pkg/utils"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
// SpaceManager return a middleware that manages space memberships
func SpaceManager(cfg config.ClaimSpaceManagement, opts ...Option) func(next http.Handler) http.Handler {
options := newOptions(opts...)
logger := options.Logger
var cm claimsmapper.ClaimsMapper
if cfg.Enabled {
cm = claimsmapper.NewClaimsMapper(cfg.Regexp, cfg.Mapping)
}
return func(next http.Handler) http.Handler {
return &claimSpaceManager{
next: next,
logger: logger,
gws: options.RevaGatewaySelector,
mapper: cm,
serviceAccountID: options.ServiceAccountID,
serviceAccountSecret: options.ServiceAccountSecret,
claimName: cfg.Claim,
enabled: cfg.Enabled,
}
}
}
type claimSpaceManager struct {
next http.Handler
logger log.Logger
gws pool.Selectable[gateway.GatewayAPIClient]
mapper claimsmapper.ClaimsMapper
serviceAccountID string
serviceAccountSecret string
claimName string
enabled bool
}
func (csm claimSpaceManager) ServeHTTP(w http.ResponseWriter, req *http.Request) {
defer csm.next.ServeHTTP(w, req)
if !csm.enabled {
return
}
userid, spaceAssignments := csm.evaluateContext(req.Context())
if userid == "" {
// no user in context, we omit this request
return
}
ctx, gwc, err := csm.getCtx()
if err != nil {
csm.logger.Error().Err(err).Msg("could not get service user context")
return
}
// get all project spaces
res, err := gwc.ListStorageSpaces(ctx, listStorageSpaceRequest())
if err != nil {
csm.logger.Error().Err(err).Msg("error doing grpc request")
return
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing listspaces request")
return
}
for _, s := range res.GetStorageSpaces() {
hasAccess, actualPerms, err := getSpaceMemberStatus(s, userid)
if err != nil {
csm.logger.Error().Err(err).Msg("error extracting space member")
continue
}
desiredRole := conversions.RoleFromName(spaceAssignments[s.GetRoot().GetOpaqueId()])
shouldHaveAccess := desiredRole.Name != conversions.RoleUnknown
switch {
case shouldHaveAccess && !hasAccess:
// add user to space
res, err := gwc.CreateShare(ctx, createShareRequest(userid, s, desiredRole.CS3ResourcePermissions()))
if err != nil {
csm.logger.Error().Err(err).Msg("error adding space member")
continue
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing createshare request")
continue
}
case !shouldHaveAccess && hasAccess:
// remove user from space
res, err := gwc.RemoveShare(ctx, removeShareRequest(userid, s))
if err != nil {
csm.logger.Error().Err(err).Msg("error removing space member")
continue
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing removeshare request")
continue
}
case shouldHaveAccess && hasAccess && !permissionsEqual(actualPerms, desiredRole.CS3ResourcePermissions()):
// update user permissions
res, err := gwc.UpdateShare(ctx, updateShareRequest(userid, s, desiredRole.CS3ResourcePermissions()))
if err != nil {
csm.logger.Error().Err(err).Msg("error updating space member")
continue
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing updateshare request")
continue
}
}
}
}
// returns the service user context and the gateway client
func (csm claimSpaceManager) getCtx() (context.Context, gateway.GatewayAPIClient, error) {
gwc, err := csm.gws.Next()
if err != nil {
csm.logger.Error().Err(err).Msg("could not get gateway client")
return nil, nil, err
}
ctx, err := utils.GetServiceUserContext(csm.serviceAccountID, gwc, csm.serviceAccountSecret)
return ctx, gwc, err
}
// returns the userid and the space assignments from the context
func (csm claimSpaceManager) evaluateContext(ctx context.Context) (string, map[string]string) {
u, _ := revactx.ContextGetUser(ctx)
return u.GetId().GetOpaqueId(), csm.getSpaceAssignments(ctx)
}
// returns a map[spaceID]role
func (csm claimSpaceManager) getSpaceAssignments(ctx context.Context) map[string]string {
claims := oidc.FromContext(ctx)
values, ok := claims[csm.claimName].([]any)
if !ok {
csm.logger.Error().Interface("entitlements", claims["entitlements"]).Msg("entitlements claims are not a []string")
}
assignments := make(map[string]string)
for _, ent := range values {
e, ok := ent.(string)
if !ok {
csm.logger.Error().Interface("entitlement", ent).Msg("entitlement is not a sting")
continue
}
match, spaceid, role := csm.mapper.Exec(e)
if !match {
continue
}
assignments[spaceid] = role
}
return assignments
}
func getSpaceMemberStatus(space *storageprovider.StorageSpace, userid string) (bool, *storageprovider.ResourcePermissions, error) {
var permissionsMap map[string]*storageprovider.ResourcePermissions
if err := utils.ReadJSONFromOpaque(space.GetOpaque(), "grants", &permissionsMap); err != nil {
return false, nil, err
}
for id, perm := range permissionsMap {
if id == userid {
return true, perm, nil
}
}
return false, nil, nil
}
func permissionsEqual(p1, p2 *storageprovider.ResourcePermissions) bool {
if !conversions.SufficientCS3Permissions(p1, p2) {
return false
}
if !conversions.SufficientCS3Permissions(p2, p1) {
return false
}
return true
}
func listStorageSpaceRequest() *storageprovider.ListStorageSpacesRequest {
return &storageprovider.ListStorageSpacesRequest{
Opaque: utils.AppendPlainToOpaque(nil, "unrestricted", "true"),
Filters: []*storageprovider.ListStorageSpacesRequest_Filter{
{
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{
SpaceType: "project",
},
},
},
}
}
func createShareRequest(userid string, space *storageprovider.StorageSpace, perms *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
return &collaboration.CreateShareRequest{
ResourceInfo: space.GetRootInfo(),
Grant: &collaboration.ShareGrant{
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{UserId: &userpb.UserId{
OpaqueId: userid,
}},
},
Permissions: &collaboration.SharePermissions{
Permissions: perms,
},
},
}
}
func removeShareRequest(userid string, space *storageprovider.StorageSpace) *collaboration.RemoveShareRequest {
return &collaboration.RemoveShareRequest{
Ref: &collaboration.ShareReference{
Spec: &collaboration.ShareReference_Key{
Key: &collaboration.ShareKey{
ResourceId: space.GetRoot(),
Grantee: buildGrantee(userid)},
},
},
}
}
func updateShareRequest(userid string, s *storageprovider.StorageSpace, perms *storageprovider.ResourcePermissions) *collaboration.UpdateShareRequest {
o := &types.Opaque{
Map: map[string]*types.OpaqueEntry{
"spacegrant": {},
},
}
o = utils.AppendPlainToOpaque(o, "spacetype", "project")
return &collaboration.UpdateShareRequest{
Share: &collaboration.Share{
ResourceId: s.GetRoot(),
Grantee: buildGrantee(userid),
Permissions: &collaboration.SharePermissions{Permissions: perms},
},
UpdateMask: &fieldmaskpb.FieldMask{
Paths: []string{"permissions"},
},
Opaque: o,
}
}
func buildGrantee(userid string) *storageprovider.Grantee {
return &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{
UserId: &userpb.UserId{
OpaqueId: userid,
},
},
}
}