From bc6d285d54f2a2f84abc2be70384934442b14387 Mon Sep 17 00:00:00 2001 From: Sean Trantalis Date: Mon, 25 Nov 2024 12:23:27 -0500 Subject: [PATCH] add more tests --- opentdf-dev.yaml | 20 ++--- opentdf-example.yaml | 19 ++-- opentdf-with-hsm.yaml | 114 ------------------------ service/internal/auth/casbin.go | 84 ++++------------- service/internal/auth/casbin_model.conf | 14 +++ service/internal/auth/casbin_policy.csv | 44 +++++++++ service/internal/auth/casbin_test.go | 113 ++++++++++++++++++++--- service/internal/auth/config.go | 2 +- 8 files changed, 193 insertions(+), 217 deletions(-) delete mode 100644 opentdf-with-hsm.yaml create mode 100644 service/internal/auth/casbin_model.conf create mode 100644 service/internal/auth/casbin_policy.csv diff --git a/opentdf-dev.yaml b/opentdf-dev.yaml index 5ea3bf2fe..79c0e2b86 100644 --- a/opentdf-dev.yaml +++ b/opentdf-dev.yaml @@ -49,20 +49,18 @@ server: audience: 'http://localhost:8080' issuer: http://localhost:8888/auth/realms/opentdf policy: - ## Default policy for all requests - default: #"role:standard" ## Dot notation is used to access nested claims (i.e. realm_access.roles) - claim: # realm_access.roles - ## Maps the external role to the opentdf role - ## Note: left side is used in the policy, right side is the external role - map: - # standard: opentdf-standard - # admin: opentdf-admin - - ## Custom policy (see examples https://github.com/casbin/casbin/tree/master/examples) + # Claim that represents the user (i.e. email) + username_claim: # preferred_username + # That claim to access groups (i.e. realm_access.roles) + groups_claim: # realm_access.roles + ## Extends the builtin policy + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + ## Custom policy that overrides builtin policy (see examples https://github.com/casbin/casbin/tree/master/examples) csv: #| # p, role:admin, *, *, allow - ## Custom model (see https://casbin.org/docs/syntax-for-models/) model: #| # [request_definition] diff --git a/opentdf-example.yaml b/opentdf-example.yaml index 1033c066c..993c07f96 100644 --- a/opentdf-example.yaml +++ b/opentdf-example.yaml @@ -36,17 +36,16 @@ server: audience: 'http://localhost:8080' issuer: http://keycloak:8888/auth/realms/opentdf policy: - ## Default policy for all requests - default: #"role:standard" ## Dot notation is used to access nested claims (i.e. realm_access.roles) - claim: # realm_access.roles - ## Maps the external role to the opentdf role - ## Note: left side is used in the policy, right side is the external role - map: - # standard: opentdf-standard - # admin: opentdf-admin - - ## Custom policy (see examples https://github.com/casbin/casbin/tree/master/examples) + # Claim that represents the user (i.e. email) + username_claim: # preferred_username + # That claim to access groups (i.e. realm_access.roles) + groups_claim: # realm_access.roles + ## Extends the builtin policy + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + ## Custom policy that overrides builtin policy (see examples https://github.com/casbin/casbin/tree/master/examples) csv: #| # p, role:admin, *, *, allow ## Custom model (see https://casbin.org/docs/syntax-for-models/) diff --git a/opentdf-with-hsm.yaml b/opentdf-with-hsm.yaml deleted file mode 100644 index 92871daae..000000000 --- a/opentdf-with-hsm.yaml +++ /dev/null @@ -1,114 +0,0 @@ -logger: - level: debug - type: text - output: stdout -# DB and Server configurations are defaulted for local development -# db: -# host: localhost -# port: 5432 -# user: postgres -# password: changeme -services: - kas: - enabled: true - eccertid: e1 - rsacertid: r1 - policy: - enabled: true - # list_request_limit_default: 1000 - # list_request_limit_max: 2500 - entityresolution: - enabled: true - url: http://localhost:8888/auth - clientid: 'tdf-entity-resolution' - clientsecret: 'secret' - realm: 'opentdf' - legacykeycloak: true - authorization: - enabled: true -server: - auth: - enabled: true - enforceDPoP: false - public_client_id: 'opentdf-public' - audience: 'http://localhost:8080' - issuer: http://localhost:8888/auth/realms/opentdf - clients: - - 'opentdf' - - 'opentdf-sdk' - policy: - ## Default policy for all requests - default: #"role:standard" - ## Role map is used to map external roles to opentdf roles (opentdf_role:idp_role) the benefit of this is that you - ## can use the builtin policy if desired - roles: - ## Dot notation is used to access nested claims (i.e. realm_access.roles) - claim: # realm_access.roles - ## Maps the external role to the opentdf role - ## Note: left side is used in the policy, right side is the external role - map: - # standard: opentdf-standard - # admin: opentdf-admin - ## Custom policy (see examples https://github.com/casbin/casbin/tree/master/examples) - csv: #| - # p, role:admin, *, *, allow - ## Custom model (see https://casbin.org/docs/syntax-for-models/) - model: #| - # [request_definition] - # r = sub, res, act, obj - # - # [policy_definition] - # p = sub, res, act, obj, eft - # - # [role_definition] - # g = _, _ - # - # [policy_effect] - # e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) - # - # [matchers] - # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) - cors: - enabled: false - # "*" to allow any origin or a specific domain like "https://yourdomain.com" - allowedorigins: - - '*' - # List of methods. Examples: "GET,POST,PUT" - allowedmethods: - - GET - - POST - - PATCH - - PUT - - DELETE - - OPTIONS - # List of headers that are allowed in a request - allowedheaders: - - ACCEPT - - Authorization - - Content-Type - - X-CSRF-Token - # List of response headers that browsers are allowed to access - exposedheaders: - - Link - # Sets whether credentials are included in the CORS request - allowcredentials: true - # Sets the maximum age (in seconds) of a specific CORS preflight request - maxage: 3600 - grpc: - reflectionEnabled: true # Default is false - cryptoProvider: - type: hsm - hsm: - # As configured by init-temp-keys.sh --hsm - pin: '12345' - slotlabel: 'dev-token' - keys: - - kid: r1 - alg: rsa:2048 - private: development-rsa-kas - - kid: e1 - alg: ec:secp256r1 - private: development-ec-kas - port: 8080 -opa: - embedded: true # Only for local development diff --git a/service/internal/auth/casbin.go b/service/internal/auth/casbin.go index cfee8b005..7532815f5 100644 --- a/service/internal/auth/casbin.go +++ b/service/internal/auth/casbin.go @@ -11,6 +11,8 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/opentdf/platform/service/logger" "github.com/opentdf/platform/service/pkg/util" + + _ "embed" ) var ( @@ -18,73 +20,11 @@ var ( defaultRole = "unknown" ) -var builtinPolicy = ` -## Roles (prefixed with role:) -# admin - admin -# standard - standard -# unknown - unknown role or no role - -## Resources -# Resources beginning with / are HTTP routes. Generally, this does not matter, but when HTTP routes don't map well -# with the protos this may become important. - -## Actions -# read - read the resource -# write - write to the resource -# delete - delete the resource -# unsafe - unsafe actions - -## Grouping Statements - Maps users/groups to roles -g, opentdf-admin, role:admin -g, opentdf-standard, role:standard - -# Role: Admin -## gRPC and HTTP routes -p, role:admin, *, *, allow - -## Role: Standard -## gRPC routes -p, role:standard, policy.*, read, allow -p, role:standard, kasregistry.*, read, allow -p, role:standard, kas.AccessService/Rewrap, *, allow -p, role:standard, authorization.AuthorizationService/GetDecisions, read, allow -p, role:standard, authorization.AuthorizationService/GetDecisionsByToken, read, allow - -## HTTP routes -p, role:standard, /attributes*, read, allow -p, role:standard, /namespaces*, read, allow -p, role:standard, /subject-mappings*, read, allow -p, role:standard, /resource-mappings*, read, allow -p, role:standard, /key-access-servers*, read, allow -p, role:standard, /kas/v2/rewrap, write, allow -p, role:standard, /v1/authorization, write, allow -p, role:standard, /v1/token/authorization, write, allow - -# Public routes -## gRPC routes -## for ERS, right now we don't care about requester role, just that a valid jwt is provided when the OPA engine calls (enforced in the ERS itself, not casbin) -p, role:unknown, kas.AccessService/Rewrap, *, allow -## HTTP routes -## for ERS, right now we don't care about requester role, just that a valid jwt is provided when the OPA engine calls (enforced in the ERS itself, not casbin) -p, role:unknown, /kas/v2/rewrap, *, allow -` - -var defaultModel = ` -[request_definition] -r = sub, res, act - -[policy_definition] -p = sub, res, act, eft - -[role_definition] -g = _, _ - -[policy_effect] -e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) - -[matchers] -m = g(r.sub, p.sub) && keyMatch(r.res, p.res) && keyMatch(r.act, p.act) -` +//go:embed casbin_policy.csv +var builtinPolicy string + +//go:embed casbin_model.conf +var defaultModel string type Enforcer struct { *casbin.Enforcer @@ -137,6 +77,16 @@ func NewCasbinEnforcer(c CasbinConfig, logger *logger.Logger) (*Enforcer, error) isPolicyExtended = true } + // Because we provided built in group mappings we need to add them + // if extensions and rolemap are not provided + if c.RoleMap == nil && c.Extension == "" { + c.Csv = strings.Join([]string{ + c.Csv, + "g, opentdf-admin, role:admin", + "g, opentdf-standard, role:standard", + }, "\n") + } + isDefaultAdapter := false // If adapter is not provided, use the default string adapter if c.Adapter == nil { diff --git a/service/internal/auth/casbin_model.conf b/service/internal/auth/casbin_model.conf new file mode 100644 index 000000000..d08ade40c --- /dev/null +++ b/service/internal/auth/casbin_model.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, res, act + +[policy_definition] +p = sub, res, act, eft + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch(r.res, p.res) && keyMatch(r.act, p.act) \ No newline at end of file diff --git a/service/internal/auth/casbin_policy.csv b/service/internal/auth/casbin_policy.csv new file mode 100644 index 000000000..60ffdc438 --- /dev/null +++ b/service/internal/auth/casbin_policy.csv @@ -0,0 +1,44 @@ +## Roles (prefixed with role:) +# admin - admin +# standard - standard +# unknown - unknown role or no role + +## Resources +# Resources beginning with / are HTTP routes. Generally, this does not matter, but when HTTP routes don't map well +# with the protos this may become important. + +## Actions +# read - read the resource +# write - write to the resource +# delete - delete the resource +# unsafe - unsafe actions + +# Role: Admin +## gRPC and HTTP routes +p, role:admin, *, *, allow + +## Role: Standard +## gRPC routes +p, role:standard, policy.*, read, allow +p, role:standard, kasregistry.*, read, allow +p, role:standard, kas.AccessService/Rewrap, *, allow +p, role:standard, authorization.AuthorizationService/GetDecisions, read, allow +p, role:standard, authorization.AuthorizationService/GetDecisionsByToken, read, allow + +## HTTP routes +p, role:standard, /attributes*, read, allow +p, role:standard, /namespaces*, read, allow +p, role:standard, /subject-mappings*, read, allow +p, role:standard, /resource-mappings*, read, allow +p, role:standard, /key-access-servers*, read, allow +p, role:standard, /kas/v2/rewrap, write, allow +p, role:standard, /v1/authorization, write, allow +p, role:standard, /v1/token/authorization, write, allow + +# Public routes +## gRPC routes +## for ERS, right now we don't care about requester role, just that a valid jwt is provided when the OPA engine calls (enforced in the ERS itself, not casbin) +p, role:unknown, kas.AccessService/Rewrap, *, allow +## HTTP routes +## for ERS, right now we don't care about requester role, just that a valid jwt is provided when the OPA engine calls (enforced in the ERS itself, not casbin) +p, role:unknown, /kas/v2/rewrap, *, allow \ No newline at end of file diff --git a/service/internal/auth/casbin_test.go b/service/internal/auth/casbin_test.go index f2eee02a3..97f864ba5 100644 --- a/service/internal/auth/casbin_test.go +++ b/service/internal/auth/casbin_test.go @@ -50,12 +50,24 @@ func (s *AuthnCasbinSuite) buildTokenRoles(admin bool, standard bool, roleMaps [ return roles } -func (s *AuthnCasbinSuite) newTokWithDefaultClaim(admin bool, standard bool) jwt.Token { +func (s *AuthnCasbinSuite) newTokWithDefaultClaim(admin bool, standard bool, usernameClaimName, groupClaimName string) jwt.Token { tok := jwt.New() + + if groupClaimName == "" { + groupClaimName = "roles" + } + tokenRoles := s.buildTokenRoles(admin, standard, nil) - if err := tok.Set("realm_access", map[string]interface{}{"roles": tokenRoles}); err != nil { + if err := tok.Set("realm_access", map[string]interface{}{groupClaimName: tokenRoles}); err != nil { s.T().Fatal(err) } + + if usernameClaimName != "" { + if err := tok.Set(usernameClaimName, "casbin-user"); err != nil { + s.T().Fatal(err) + } + } + return tok } @@ -302,7 +314,7 @@ func (s *AuthnCasbinSuite) Test_Enforcement() { slog.Info("running test w/ default claim", slog.String("name", name)) enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) s.Require().NoError(err, name) - tok := s.newTokWithDefaultClaim(test.roles[0], test.roles[1]) + tok := s.newTokWithDefaultClaim(test.roles[0], test.roles[1], "", "") allowed, err := enforcer.Enforce(tok, test.resource, test.action) if !test.allowed { s.Require().Error(err, name) @@ -383,12 +395,14 @@ func (s *AuthnCasbinSuite) Test_ExtendDefaultPolicies() { policyCfg.Extension = strings.Join([]string{ "p, role:standard, new.service.*, read, allow", + "g, opentdf-admin, role:admin", + "g, opentdf-standard, role:standard", }, "\n") enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) s.Require().NoError(err) // other roles denied new policy: admin - tok := s.newTokWithDefaultClaim(true, false) + tok := s.newTokWithDefaultClaim(true, false, "", "") allowed, err := enforcer.Enforce(tok, "new.service.DoSomething", "read") s.Require().NoError(err) s.True(allowed) @@ -397,7 +411,7 @@ func (s *AuthnCasbinSuite) Test_ExtendDefaultPolicies() { s.True(allowed) // other roles denied new policy: standard - tok = s.newTokWithDefaultClaim(false, true) + tok = s.newTokWithDefaultClaim(false, true, "", "") allowed, err = enforcer.Enforce(tok, "new.service.DoSomething", "read") s.Require().NoError(err) s.True(allowed) @@ -413,33 +427,37 @@ func (s *AuthnCasbinSuite) Test_ExtendDefaultPolicies_MalformedErrors() { enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) s.Require().NoError(err) - tok := s.newTokWithDefaultClaim(true, false) + tok := s.newTokWithDefaultClaim(true, false, "", "") allowed, err := enforcer.Enforce(tok, "policy.attributes.DoSomething", "read") s.Require().NoError(err) s.True(allowed) // missing 'p' policyCfg.Extension = strings.Join([]string{ + "g, opentdf-admin, role:admin", + "g, opentdf-standard, role:standard", "role:admin, new.service.DoSomething, *", }, "\n") enforcer, err = NewCasbinEnforcer(CasbinConfig{ PolicyConfig: policyCfg, }, logger.CreateTestLogger()) s.Require().NoError(err) - tok = s.newTokWithDefaultClaim(true, false) + tok = s.newTokWithDefaultClaim(true, false, "", "") allowed, err = enforcer.Enforce(tok, "policy.attributes.DoSomething", "read") s.Require().NoError(err) s.True(allowed) // missing effect policyCfg.Extension = strings.Join([]string{ + "g, opentdf-admin, role:admin", + "g, opentdf-standard, role:standard", "p, role:admin, new.service.DoSomething, *", }, "\n") enforcer, err = NewCasbinEnforcer(CasbinConfig{ PolicyConfig: policyCfg, }, logger.CreateTestLogger()) s.Require().NoError(err) - tok = s.newTokWithDefaultClaim(true, false) + tok = s.newTokWithDefaultClaim(true, false, "", "") allowed, err = enforcer.Enforce(tok, "policy.attributes.DoSomething", "read") s.Require().NoError(err) s.True(allowed) @@ -452,26 +470,28 @@ func (s *AuthnCasbinSuite) Test_ExtendDefaultPolicies_MalformedErrors() { PolicyConfig: policyCfg, }, logger.CreateTestLogger()) s.Require().NoError(err) - tok = s.newTokWithDefaultClaim(true, false) + tok = s.newTokWithDefaultClaim(true, false, "", "") allowed, err = enforcer.Enforce(tok, "policy.attributes.DoSomething", "read") s.Require().NoError(err) s.True(allowed) // missing role prefix policyCfg.Extension = strings.Join([]string{ + "g, opentdf-admin, role:admin", + "g, opentdf-standard, role:standard", "p, admin, new.service.DoSomething, *", }, "\n") enforcer, err = NewCasbinEnforcer(CasbinConfig{ PolicyConfig: policyCfg, }, logger.CreateTestLogger()) s.Require().NoError(err) - tok = s.newTokWithDefaultClaim(true, false) + tok = s.newTokWithDefaultClaim(true, false, "", "") allowed, err = enforcer.Enforce(tok, "policy.attributes.DoSomething", "read") s.Require().NoError(err) s.True(allowed) } -func (s *AuthnCasbinSuite) Test_SetDefaultPolicy() { +func (s *AuthnCasbinSuite) Test_SetBuiltinPolicy() { policyCfg := PolicyConfig{} err := defaults.Set(&policyCfg) s.Require().NoError(err) @@ -488,7 +508,7 @@ func (s *AuthnCasbinSuite) Test_SetDefaultPolicy() { s.Require().NoError(err) // unauthorized role - tok := s.newTokWithDefaultClaim(false, false) + tok := s.newTokWithDefaultClaim(false, false, "", "") allowed, err := enforcer.Enforce(tok, "new.hello.World", "read") s.Require().Error(err) s.False(allowed) @@ -503,7 +523,7 @@ func (s *AuthnCasbinSuite) Test_SetDefaultPolicy() { s.False(allowed) // other roles denied new policy: admin - tok = s.newTokWithDefaultClaim(true, false) + tok = s.newTokWithDefaultClaim(true, false, "", "") allowed, err = enforcer.Enforce(tok, "new.hello.World", "read") s.Require().NoError(err) s.True(allowed) @@ -518,7 +538,7 @@ func (s *AuthnCasbinSuite) Test_SetDefaultPolicy() { s.False(allowed) // other roles denied new policy: standard - tok = s.newTokWithDefaultClaim(false, true) + tok = s.newTokWithDefaultClaim(false, true, "", "") allowed, err = enforcer.Enforce(tok, "new.hello.World", "read") s.Require().NoError(err) s.True(allowed) @@ -532,3 +552,68 @@ func (s *AuthnCasbinSuite) Test_SetDefaultPolicy() { s.Require().Error(err) s.False(allowed) } + +func (s *AuthnCasbinSuite) Test_Username_Policy() { + policyCfg := PolicyConfig{} + err := defaults.Set(&policyCfg) + s.Require().NoError(err) + + policyCfg.Extension = strings.Join([]string{ + "p, casbin-user, new.service.*, read, allow", + }, "\n") + + enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) + s.Require().NoError(err) + + tok := s.newTokWithDefaultClaim(true, false, "preferred_username", "") + allowed, err := enforcer.Enforce(tok, "new.service.DoSomething", "read") + s.Require().NoError(err) + s.True(allowed) + + allowed, err = enforcer.Enforce(tok, "policy.attributes.List", "read") + s.Require().Error(err) + s.False(allowed) +} + +func (s *AuthnCasbinSuite) Test_Override_Of_Username_Claim() { + policyCfg := PolicyConfig{} + err := defaults.Set(&policyCfg) + s.Require().NoError(err) + + policyCfg.UserNameClaim = "username" + policyCfg.Extension = strings.Join([]string{ + "p, casbin-user, new.service.*, read, allow", + }, "\n") + + enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) + s.Require().NoError(err) + + tok := s.newTokWithDefaultClaim(true, false, "username", "") + allowed, err := enforcer.Enforce(tok, "new.service.DoSomething", "read") + s.Require().NoError(err) + s.True(allowed) + + allowed, err = enforcer.Enforce(tok, "policy.attributes.List", "read") + s.Require().Error(err) + s.False(allowed) +} + +func (s *AuthnCasbinSuite) Test_Override_Of_Groups_Claim() { + policyCfg := PolicyConfig{} + err := defaults.Set(&policyCfg) + s.Require().NoError(err) + + policyCfg.GroupsClaim = "realm_access.groups" + + enforcer, err := NewCasbinEnforcer(CasbinConfig{PolicyConfig: policyCfg}, logger.CreateTestLogger()) + s.Require().NoError(err) + + tok := s.newTokWithDefaultClaim(false, true, "", "groups") + allowed, err := enforcer.Enforce(tok, "new.service.DoSomething", "read") + s.Require().Error(err) + s.False(allowed) + + allowed, err = enforcer.Enforce(tok, "policy.attributes.List", "read") + s.Require().NoError(err) + s.True(allowed) +} diff --git a/service/internal/auth/config.go b/service/internal/auth/config.go index d28e762cd..f6bab0f2f 100644 --- a/service/internal/auth/config.go +++ b/service/internal/auth/config.go @@ -30,7 +30,7 @@ type AuthNConfig struct { //nolint:revive // AuthNConfig is a valid name type PolicyConfig struct { Builtin string `mapstructure:"-" json:"-"` // Username claim to use for user information - UserNameClaim string `mapstructure:"username_claim" json:"username_claim" default:"sub"` + UserNameClaim string `mapstructure:"username_claim" json:"username_claim" default:"preferred_username"` // Claim to use for group/role information GroupsClaim string `mapstructure:"groups_claim" json:"group_claim" default:"realm_access.roles"` // Deprecated: Use GroupClain instead