diff --git a/docs/configuration.md b/docs/configuration.md index 3a459cad99..5dfa0968bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -136,3 +136,9 @@ services: policy: enabled: true ``` + +### Authorization + +| Field | Description | Default | +| --- | --- | --- | +| `enabled` | Enable the Authorization diff --git a/opentdf-example.yaml b/opentdf-example.yaml index e94ed605c7..5bcf1d3f2d 100644 --- a/opentdf-example.yaml +++ b/opentdf-example.yaml @@ -31,17 +31,15 @@ server: policy: ## Default policy for all requests default: #"role:readonly" - ## 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: - # readonly: opentdf-readonly - # admin: opentdf-admin - # org-admin: opentdf-org-admin + ## 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: + # readonly: opentdf-readonly + # admin: opentdf-admin + # org-admin: opentdf-org-admin + ## Custom policy (see examples https://github.com/casbin/casbin/tree/master/examples) csv: #| # p, role:org-admin, policy:attributes, *, *, allow diff --git a/service/internal/auth/casbin.go b/service/internal/auth/casbin.go index 1952cd90be..c9b0833b04 100644 --- a/service/internal/auth/casbin.go +++ b/service/internal/auth/casbin.go @@ -5,12 +5,13 @@ import ( "fmt" "strings" + "log/slog" + "github.com/casbin/casbin/v2" casbinModel "github.com/casbin/casbin/v2/model" stringadapter "github.com/casbin/casbin/v2/persist/string-adapter" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/opentdf/platform/service/pkg/util" - "golang.org/x/exp/slog" ) var rolePrefix = "role:" @@ -151,6 +152,8 @@ func NewCasbinEnforcer(c CasbinConfig) (*Enforcer, error) { pStr = c.Csv } + slog.Debug("creating casbin enforcer", slog.Any("config", c)) + m, err := casbinModel.NewModelFromString(mStr) if err != nil { return nil, fmt.Errorf("failed to create casbin model: %w", err) @@ -208,6 +211,7 @@ func (e Enforcer) Enforce(token jwt.Token, resource, action string) (bool, error } func (e Enforcer) buildSubjectFromToken(t jwt.Token) (casbinSubject, error) { + slog.Debug("building subject from token", slog.Any("token", t)) roles, err := e.extractRolesFromToken(t) if err != nil { return casbinSubject{}, err @@ -220,6 +224,7 @@ func (e Enforcer) buildSubjectFromToken(t jwt.Token) (casbinSubject, error) { } func (e Enforcer) extractRolesFromToken(t jwt.Token) ([]string, error) { + slog.Debug("extracting roles from token", slog.Any("token", t)) roles := []string{} roleClaim := defaultRoleClaim @@ -232,28 +237,39 @@ func (e Enforcer) extractRolesFromToken(t jwt.Token) ([]string, error) { roleMap = e.Config.RoleMap } - p := strings.Split(roleClaim, ".") - if n, ok := t.Get(p[0]); ok { - // use dotnotation if the claim is nested - r := n - if len(p) > 1 { - r = util.Dotnotation(n.(map[string]interface{}), strings.Join(p[1:], ".")) - if r == nil { - slog.Warn("role claim not found", slog.String("claim", roleClaim), slog.Any("roles", n)) - return nil, nil - } + selectors := strings.Split(roleClaim, ".") + claim, exists := t.Get(selectors[0]) + if !exists { + slog.Warn("claim not found", slog.String("claim", roleClaim), slog.Any("token", t)) + return nil, nil + } + slog.Debug("root claim found", slog.String("claim", roleClaim), slog.Any("claims", claim)) + // use dotnotation if the claim is nested + if len(selectors) > 1 { + claimMap, ok := claim.(map[string]interface{}) + if !ok { + slog.Warn("claim is not of type map[string]interface{}", slog.String("claim", roleClaim), slog.Any("claims", claim)) + return nil, nil + } + claim = util.Dotnotation(claimMap, strings.Join(selectors[1:], ".")) + if claim == nil { + slog.Warn("claim not found", slog.String("claim", roleClaim), slog.Any("claims", claim)) + return nil, nil } + } - // TODO test the type because an array of strings will panic - for _, v := range r.([]interface{}) { - switch vv := v.(type) { - case string: - roles = append(roles, vv) - default: + // check the type of the role claim + switch v := claim.(type) { + case string: + roles = append(roles, v) + case []interface{}: + for _, rr := range v { + if r, ok := rr.(string); ok { + roles = append(roles, r) } } - } else { - slog.Warn("role claim not found", slog.String("claim", roleClaim), slog.Any("token", t)) + default: + slog.Warn("could not get claim type", slog.String("selector", roleClaim), slog.Any("claims", claim)) return nil, nil } diff --git a/service/internal/auth/casbin_test.go b/service/internal/auth/casbin_test.go index 0a65df44eb..b2f590a7c5 100644 --- a/service/internal/auth/casbin_test.go +++ b/service/internal/auth/casbin_test.go @@ -58,26 +58,36 @@ func (s *AuthnCasbinSuite) buildTokenRoles(orgAdmin bool, admin bool, readonly b func (s *AuthnCasbinSuite) newTokWithDefaultClaim(orgAdmin bool, admin bool, readonly bool) (string, jwt.Token) { tok := jwt.New() - tok.Set("realm_access", map[string]interface{}{ - "roles": s.buildTokenRoles(orgAdmin, admin, readonly, nil), - }) + tokenRoles := s.buildTokenRoles(orgAdmin, admin, readonly, nil) + if err := tok.Set("realm_access", map[string]interface{}{"roles": tokenRoles}); err != nil { + s.T().Fatal(err) + } return "", tok } func (s *AuthnCasbinSuite) newTokenWithCustomClaim(orgAdmin bool, admin bool, readonly bool) (string, jwt.Token) { tok := jwt.New() - tok.Set("test", map[string]interface{}{ - "test_roles": s.buildTokenRoles(orgAdmin, admin, readonly, nil), - }) - - return "test.test_roles", tok + tokenRoles := s.buildTokenRoles(orgAdmin, admin, readonly, nil) + if err := tok.Set("test", map[string]interface{}{"test_roles": map[string]interface{}{"roles": tokenRoles}}); err != nil { + s.T().Fatal(err) + } + return "test.test_roles.roles", tok } func (s *AuthnCasbinSuite) newTokenWithCustomRoleMap(orgAdmin bool, admin bool, readonly bool) (string, jwt.Token) { tok := jwt.New() - tok.Set("realm_access", map[string]interface{}{ - "roles": s.buildTokenRoles(orgAdmin, admin, readonly, []string{"test-org-admin", "test-admin", "test-readonly"}), - }) + tokenRoles := s.buildTokenRoles(orgAdmin, admin, readonly, []string{"test-org-admin", "test-admin", "test-readonly"}) + if err := tok.Set("realm_access", map[string]interface{}{"roles": tokenRoles}); err != nil { + s.T().Fatal(err) + } + return "", tok +} + +func (s *AuthnCasbinSuite) newTokenWithCilentID() (string, jwt.Token) { + tok := jwt.New() + if err := tok.Set("client_id", "test"); err != nil { + s.T().Fatal(err) + } return "", tok } @@ -118,7 +128,7 @@ func (s *AuthnCasbinSuite) Test_NewEnforcerWithCustomModel() { Csv: "p, role:unknown, res, act, allow", }, }) - s.NoError(err) + s.Require().NoError(err) s.NotNil(enforcer) tok := jwt.New() @@ -126,7 +136,7 @@ func (s *AuthnCasbinSuite) Test_NewEnforcerWithCustomModel() { "roles": []interface{}{"role:unknown"}, }) allowed, err := enforcer.Enforce(tok, "", "") - s.NoError(err) + s.Require().NoError(err) s.True(allowed) } @@ -154,7 +164,7 @@ func (s *AuthnCasbinSuite) Test_Enforcement() { resource string action string }{ - // org-admin role + // // org-admin role { allowed: true, roles: orgadmin, @@ -285,46 +295,44 @@ func (s *AuthnCasbinSuite) Test_Enforcement() { for _, test := range tests { should := "should" - if !test.allowed { - should = "should not" - } actor := "" - if test.roles[0] { + switch { + case test.roles[0]: actor = "org-admin" - } else if test.roles[1] { + case test.roles[1]: actor = "admin" - } else if test.roles[2] { + case test.roles[2]: actor = "readonly" - } else { + default: actor = "undefined" } name := fmt.Sprintf("%s **%s** be allowed to _%s_ %s resource", actor, should, test.action, test.resource) slog.Info("running test w/ default claim", slog.String("name", name)) enforcer, err := NewCasbinEnforcer(CasbinConfig{}) - s.NoError(err) + s.Require().NoError(err) _, tok := s.newTokWithDefaultClaim(test.roles[0], test.roles[1], test.roles[2]) allowed, err := enforcer.Enforce(tok, test.resource, test.action) if !test.allowed { - s.NotNil(err) + s.Require().Error(err) } else { - s.NoError(err) + s.Require().NoError(err) } s.Equal(test.allowed, allowed) slog.Info("running test w/ custom claim", slog.String("name", name)) enforcer, err = NewCasbinEnforcer(CasbinConfig{ PolicyConfig: PolicyConfig{ - RoleClaim: "test.test_roles", + RoleClaim: "test.test_roles.roles", }, }) - s.NoError(err) + s.Require().NoError(err) _, tok = s.newTokenWithCustomClaim(test.roles[0], test.roles[1], test.roles[2]) allowed, err = enforcer.Enforce(tok, test.resource, test.action) if !test.allowed { - s.NotNil(err) + s.Require().Error(err) } else { - s.NoError(err) + s.Require().NoError(err) } s.Equal(test.allowed, allowed) @@ -338,14 +346,42 @@ func (s *AuthnCasbinSuite) Test_Enforcement() { }, }, }) - s.NoError(err) + s.Require().NoError(err) _, tok = s.newTokenWithCustomRoleMap(test.roles[0], test.roles[1], test.roles[2]) allowed, err = enforcer.Enforce(tok, test.resource, test.action) if !test.allowed { - s.NotNil(err) + s.Require().Error(err) } else { - s.NoError(err) + s.Require().NoError(err) + } + s.Equal(test.allowed, allowed) + slog.Info("running test w/ client_id", slog.String("name", name)) + var roleMap = make(map[string]string) + if test.roles[0] { + roleMap["org-admin"] = "test" + } + if test.roles[1] { + roleMap["admin"] = "test" + } + if test.roles[2] { + roleMap["readonly"] = "test" + } + + enforcer, err = NewCasbinEnforcer(CasbinConfig{ + PolicyConfig: PolicyConfig{ + RoleClaim: "client_id", + RoleMap: roleMap, + }, + }) + s.Require().NoError(err) + _, tok = s.newTokenWithCilentID() + allowed, err = enforcer.Enforce(tok, test.resource, test.action) + if !test.allowed { + s.Require().Error(err) + } else { + s.Require().NoError(err) } s.Equal(test.allowed, allowed) } + } diff --git a/service/internal/auth/config.go b/service/internal/auth/config.go index b068ed90ad..79bc134f1c 100644 --- a/service/internal/auth/config.go +++ b/service/internal/auth/config.go @@ -14,13 +14,13 @@ type AuthNConfig struct { Audience string `yaml:"audience" json:"audience"` Clients []string `yaml:"clients" json:"clients"` OIDCConfiguration `yaml:"-" json:"-"` - Policy PolicyConfig `yaml:"policy" json:"policy"` + Policy PolicyConfig `yaml:"policy" json:"policy" mapstructure:"policy"` } type PolicyConfig struct { Default string `yaml:"default" json:"default"` - RoleClaim string `yaml:"claim" json:"claim"` - RoleMap map[string]string `yaml:"map" json:"map"` + RoleClaim string `yaml:"claim" json:"claim" mapstructure:"claim"` + RoleMap map[string]string `yaml:"map" json:"map" mapstructure:"map"` Csv string `yaml:"csv" json:"csv"` Model string `yaml:"model" json:"model"` }