diff --git a/service/internal/auth/authz/casbin/casbin_test.go b/service/internal/auth/authz/casbin/casbin_test.go index e1c4456b8f..d55a64666e 100644 --- a/service/internal/auth/authz/casbin/casbin_test.go +++ b/service/internal/auth/authz/casbin/casbin_test.go @@ -169,6 +169,35 @@ func (s *CasbinAuthorizerSuite) TestAuthorizeV2_AdminWildcard() { s.Equal(authz.ModeV2, decision.Mode) } +func (s *CasbinAuthorizerSuite) TestAuthorizeV2_DefaultPolicyIncludesDefaultRoleGroupings() { + cfg := authz.Config{ + Version: "v2", + PolicyConfig: authz.PolicyConfig{ + GroupsClaim: "realm_access.roles", + }, + Logger: s.logger, + } + + authorizer, err := NewAuthorizer(cfg) + s.Require().NoError(err) + + token := createTestToken(s.T(), map[string]interface{}{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"opentdf-admin"}, + }, + }) + req := &authz.Request{ + Token: token, + RPC: "/policy.attributes.AttributesService/UpdateAttribute", + Action: "write", + } + + decision, err := authorizer.Authorize(s.T().Context(), req) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.Allowed, "default v2 policy should map opentdf-admin to role:admin") +} + func (s *CasbinAuthorizerSuite) TestAuthorizeV2_NamespaceScopedAccess() { // Policy: hr-admin can only access HR namespace cfg := authz.Config{ diff --git a/service/internal/auth/authz/casbin/policy.csv b/service/internal/auth/authz/casbin/policy.csv index e1adf08671..ce62189253 100644 --- a/service/internal/auth/authz/casbin/policy.csv +++ b/service/internal/auth/authz/casbin/policy.csv @@ -13,10 +13,12 @@ # Admin Role - Full Access # ============================================================================ p, role:admin, *, *, allow +g, role:opentdf-admin, role:admin # ============================================================================ # Standard Role - Authenticated Users # ============================================================================ +g, role:opentdf-standard, role:standard # Discovery and health endpoints p, role:standard, /wellknownconfiguration.WellKnownService/*, *, allow diff --git a/service/internal/auth/interceptor_authz_test.go b/service/internal/auth/interceptor_authz_test.go index 4460dbfb33..3fc62f31ec 100644 --- a/service/internal/auth/interceptor_authz_test.go +++ b/service/internal/auth/interceptor_authz_test.go @@ -5,12 +5,15 @@ import ( "strings" "testing" + "connectrpc.com/connect" "github.com/creasty/defaults" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/service/internal/auth/authz" _ "github.com/opentdf/platform/service/internal/auth/authz/casbin" // Register casbin authorizer "github.com/opentdf/platform/service/logger" "github.com/stretchr/testify/suite" + "google.golang.org/grpc" ) // InterceptorAuthzSuite tests the authorization flow through the interceptor @@ -456,6 +459,47 @@ p, role:finance-admin, /policy.attributes.AttributesService/*, namespace=finance s.True(decision.Allowed, "finance-admin should be allowed with namespace=finance dimension") } +func (s *InterceptorAuthzSuite) TestAuthorizeV2_InvokesRegisteredResolver() { + csvPolicy := "p, role:hr-admin, /policy.attributes.AttributesService/*, namespace=hr, allow" + registry := authz.NewResolverRegistry() + scopedRegistry := registry.ScopedForService(&grpc.ServiceDesc{ + ServiceName: "policy.attributes.AttributesService", + Methods: []grpc.MethodDesc{ + {MethodName: "UpdateAttribute"}, + }, + }) + + resolverCalled := false + scopedRegistry.MustRegister("UpdateAttribute", func(_ context.Context, _ connect.AnyRequest) (authz.ResolverContext, error) { + resolverCalled = true + resolverCtx := authz.NewResolverContext() + res := resolverCtx.NewResource() + res.AddDimension("namespace", "hr") + resolverCtx.SetResolvedData("attribute", "resolved") + return resolverCtx, nil + }) + + authn := &Authentication{ + logger: s.logger, + authorizer: s.createV2Authorizer(csvPolicy), + authzResolverRegistry: registry, + } + + req := &authzTestRequest{ + Request: connect.NewRequest(&attributes.GetAttributeRequest{}), + procedure: "/policy.attributes.AttributesService/UpdateAttribute", + } + + result := authn.authorize(s.T().Context(), s.logger, s.newTokenWithRoles("hr-admin"), req, ActionWrite) + + s.Require().NoError(result.err) + s.Require().NotNil(result.decision) + s.True(result.decision.Allowed) + s.True(resolverCalled, "registered resolver should be invoked") + s.Require().NotNil(result.resourceContext) + s.Equal("resolved", result.resourceContext.GetResolvedData("attribute")) +} + func (s *InterceptorAuthzSuite) TestV2_EmptyToken() { csvPolicy := "p, role:admin, *, *, allow" authorizer := s.createV2Authorizer(csvPolicy) @@ -475,6 +519,15 @@ func (s *InterceptorAuthzSuite) TestV2_EmptyToken() { s.False(decision.Allowed, "empty token should be denied") } +type authzTestRequest struct { + *connect.Request[attributes.GetAttributeRequest] + procedure string +} + +func (r *authzTestRequest) Spec() connect.Spec { + return connect.Spec{Procedure: r.procedure} +} + // ============================================================================= // Action Mapping Tests (used by getAction in the interceptor) // ============================================================================= diff --git a/service/internal/server/server.go b/service/internal/server/server.go index 7fa9496f91..20c92c4e17 100644 --- a/service/internal/server/server.go +++ b/service/internal/server/server.go @@ -21,6 +21,7 @@ import ( "github.com/opentdf/platform/sdk" sdkAudit "github.com/opentdf/platform/sdk/audit" "github.com/opentdf/platform/service/internal/auth" + "github.com/opentdf/platform/service/internal/auth/authz" "github.com/opentdf/platform/service/internal/security" "github.com/opentdf/platform/service/internal/server/memhttp" "github.com/opentdf/platform/service/logger" @@ -70,6 +71,9 @@ type Config struct { EnablePprof bool `mapstructure:"enable_pprof" json:"enable_pprof" default:"false"` // Trace is for configuring open telemetry based tracing. Trace tracing.Config `mapstructure:"trace" json:"trace"` + + // AuthzResolverRegistry contains service-registered resolvers used by the auth interceptor. + AuthzResolverRegistry *authz.ResolverRegistry `mapstructure:"-" json:"-"` } func (c Config) LogValue() slog.Value { @@ -265,6 +269,7 @@ func NewOpenTDFServer(config Config, logger *logger.Logger, cacheManager *cache. config.Auth, logger, config.WellKnownConfigRegister, + auth.WithAuthzResolverRegistry(config.AuthzResolverRegistry), ) if err != nil { return nil, fmt.Errorf("failed to create authentication interceptor: %w", err) @@ -352,7 +357,8 @@ func newHTTPServer(c Config, connectRPC http.Handler, extraHTTP http.Handler, a effectiveExposed := c.CORS.EffectiveExposedHeaders() // Log effective CORS config for operator visibility - l.Info("CORS middleware enabled", + l.Info( + "CORS middleware enabled", slog.Any("allowed_origins", c.CORS.AllowedOrigins), slog.Any("effective_methods", effectiveMethods), slog.Any("effective_headers", effectiveHeaders), diff --git a/service/pkg/server/start.go b/service/pkg/server/start.go index f59f152e8d..d1be8c37a2 100644 --- a/service/pkg/server/start.go +++ b/service/pkg/server/start.go @@ -189,6 +189,11 @@ func Start(f ...StartOptions) error { cfg.Server.CORS.AdditionalExposedHeaders = append(cfg.Server.CORS.AdditionalExposedHeaders, startConfig.additionalCORSExposedHeaders...) } + // Create the global authz resolver registry before the server/authenticator. + // Services receive scoped views of this same registry during startup. + authzResolverRegistry := authz.NewResolverRegistry() + cfg.Server.AuthzResolverRegistry = authzResolverRegistry + // Create new server for grpc & http. Also will support in process grpc potentially too logger.Debug("initializing opentdf server") cfg.Server.WellKnownConfigRegister = wellknown.RegisterConfiguration @@ -283,10 +288,6 @@ func Start(f ...StartOptions) error { defer client.Close() - // Create the global authz resolver registry - // Services will receive scoped registries that can only register resolvers for their own methods - authzResolverRegistry := authz.NewResolverRegistry() - logger.Info("starting services") err = startServices(ctx, startServicesParams{ cfg: cfg,