Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions service/internal/auth/authz/casbin/casbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@ func (a *Authorizer) extractSubjects(req *authz.Request) []string {
if username := a.extractUsernameFromToken(req.Token); username != "" {
subjects = append(subjects, username)
}

// Treat the configured OAuth client ID claim as a first-class subject.
// This allows policy to authorize service clients directly, e.g.
// p, kas-a, /policy.kasregistry.KeyAccessServerRegistryService/ListKeys, kas_uri=http://kas-a, allow
if clientID := a.extractClientIDFromToken(req.Token); clientID != "" {
subjects = append(subjects, clientID)
}
}

// Extract roles from userInfo
Expand All @@ -375,6 +382,33 @@ func (a *Authorizer) extractSubjects(req *authz.Request) []string {
return subjects
}

// extractClientIDFromToken extracts and validates the configured OAuth client ID claim.
func (a *Authorizer) extractClientIDFromToken(token jwt.Token) string {
if token == nil || a.baseConfig.ClientIDClaim == "" {
return ""
}

claim, found := token.Get(a.baseConfig.ClientIDClaim)
if !found {
return ""
}

clientID, ok := claim.(string)
if !ok || clientID == "" {
return ""
}

if strings.HasPrefix(clientID, rolePrefix) {
a.logger.Warn("ignoring client ID subject with reserved role prefix",
slog.String("claim", a.baseConfig.ClientIDClaim),
slog.String("prefix", rolePrefix),
)
return ""
}

return clientID
}

// extractUsernameFromToken extracts and validates username subject from token.
func (a *Authorizer) extractUsernameFromToken(token jwt.Token) string {
if token == nil || a.baseConfig.UserNameClaim == "" {
Expand Down
93 changes: 93 additions & 0 deletions service/internal/auth/authz/casbin/casbin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,99 @@ func (s *CasbinAuthorizerSuite) TestAuthorizeV1_PathHandlingHeuristic() {
s.Equal("/http/path", receivedResources[1], "HTTP path should keep leading slash")
}

func (s *CasbinAuthorizerSuite) TestAuthorizeV2_ClientIDSubjectKASKeyScope() {
cfg := authz.Config{
Version: "v2",
PolicyConfig: authz.PolicyConfig{
ClientIDClaim: "azp",
Csv: `p, kas-a, /policy.kasregistry.KeyAccessServerRegistryService/ListKeys, kas_uri=http://localhost:9081, allow
p, kas-a, /policy.kasregistry.KeyAccessServerRegistryService/GetKey, kas_uri=http://localhost:9081, allow
p, kas-b, /policy.kasregistry.KeyAccessServerRegistryService/ListKeys, kas_uri=http://localhost:9082, allow
p, kas-b, /policy.kasregistry.KeyAccessServerRegistryService/GetKey, kas_uri=http://localhost:9082, allow`,
},
Logger: s.logger,
}

authorizer, err := NewAuthorizer(cfg)
s.Require().NoError(err)

kasAToken := createTestToken(s.T(), map[string]interface{}{"azp": "kas-a"})
kasBToken := createTestToken(s.T(), map[string]interface{}{"azp": "kas-b"})

kasAKeysReq := &authz.Request{
Token: kasAToken,
RPC: "/policy.kasregistry.KeyAccessServerRegistryService/ListKeys",
ResourceContext: &authz.ResolverContext{
Resources: []*authz.ResolverResource{
{"kas_uri": "http://localhost:9081"},
},
},
}
decision, err := authorizer.Authorize(context.Background(), kasAKeysReq)
s.Require().NoError(err)
s.True(decision.Allowed, "kas-a should list kas-a keys")
s.Equal("kas-a", decision.MatchedPolicy)

kasBKeysReq := &authz.Request{
Token: kasAToken,
RPC: "/policy.kasregistry.KeyAccessServerRegistryService/ListKeys",
ResourceContext: &authz.ResolverContext{
Resources: []*authz.ResolverResource{
{"kas_uri": "http://localhost:9082"},
},
},
}
decision, err = authorizer.Authorize(context.Background(), kasBKeysReq)
s.Require().NoError(err)
s.False(decision.Allowed, "kas-a should not list kas-b keys")

unscopedListReq := &authz.Request{
Token: kasAToken,
RPC: "/policy.kasregistry.KeyAccessServerRegistryService/ListKeys",
}
decision, err = authorizer.Authorize(context.Background(), unscopedListReq)
s.Require().NoError(err)
s.False(decision.Allowed, "kas-a should not perform an unscoped key list")

kasBGetReq := &authz.Request{
Token: kasBToken,
RPC: "/policy.kasregistry.KeyAccessServerRegistryService/GetKey",
ResourceContext: &authz.ResolverContext{
Resources: []*authz.ResolverResource{
{"kas_uri": "http://localhost:9082"},
},
},
}
decision, err = authorizer.Authorize(context.Background(), kasBGetReq)
s.Require().NoError(err)
s.True(decision.Allowed, "kas-b should get kas-b keys")
s.Equal("kas-b", decision.MatchedPolicy)
}

func (s *CasbinAuthorizerSuite) TestAuthorizeV2_ClientIDSubjectWithReservedRolePrefixIsIgnored() {
cfg := authz.Config{
Version: "v2",
PolicyConfig: authz.PolicyConfig{
ClientIDClaim: "azp",
Csv: `p, role:admin, /policy.kasregistry.KeyAccessServerRegistryService/ListKeys, *, allow`,
},
Logger: s.logger,
}

authorizer, err := NewAuthorizer(cfg)
s.Require().NoError(err)

token := createTestToken(s.T(), map[string]interface{}{"azp": "role:admin"})
req := &authz.Request{
Token: token,
RPC: "/policy.kasregistry.KeyAccessServerRegistryService/ListKeys",
}

decision, err := authorizer.Authorize(context.Background(), req)
s.Require().NoError(err)
s.False(decision.Allowed, "client ID with reserved role prefix must not match role subjects")
}

// Helper function to create test JWT tokens
func createTestToken(t *testing.T, claims map[string]interface{}) jwt.Token {
t.Helper()
Expand Down
24 changes: 23 additions & 1 deletion service/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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"
Expand All @@ -48,6 +49,18 @@ func (e Error) Error() string {
return string(e)
}

type openTDFServerOptions struct {
authzResolverRegistry *authz.ResolverRegistry
}

type OpenTDFServerOption func(*openTDFServerOptions)

func WithAuthzResolverRegistry(registry *authz.ResolverRegistry) OpenTDFServerOption {
return func(options *openTDFServerOptions) {
options.authzResolverRegistry = registry
}
}

// Configurations for the server
type Config struct {
Auth auth.Config `mapstructure:"auth" json:"auth"`
Expand Down Expand Up @@ -251,20 +264,29 @@ type inProcessServer struct {
*ConnectRPC
}

func NewOpenTDFServer(config Config, logger *logger.Logger, cacheManager *cache.Manager) (*OpenTDFServer, error) {
func NewOpenTDFServer(config Config, logger *logger.Logger, cacheManager *cache.Manager, opts ...OpenTDFServerOption) (*OpenTDFServer, error) {
var (
authN *auth.Authentication
err error
)
options := openTDFServerOptions{}
for _, opt := range opts {
opt(&options)
}

// Add authN interceptor
// TODO Remove this conditional once we move to the hardening phase (https://github.com/opentdf/platform/issues/381)
if config.Auth.Enabled {
authOpts := []auth.AuthenticatorOption{}
if options.authzResolverRegistry != nil {
authOpts = append(authOpts, auth.WithAuthzResolverRegistry(options.authzResolverRegistry))
}
authN, err = auth.NewAuthenticator(
context.Background(),
config.Auth,
logger,
config.WellKnownConfigRegister,
authOpts...,
)
if err != nil {
return nil, fmt.Errorf("failed to create authentication interceptor: %w", err)
Expand Down
10 changes: 5 additions & 5 deletions service/pkg/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,14 @@ func Start(f ...StartOptions) error {
cfg.Server.CORS.AdditionalExposedHeaders = append(cfg.Server.CORS.AdditionalExposedHeaders, startConfig.additionalCORSExposedHeaders...)
}

// Create the global authz resolver registry.
// It is shared by the auth interceptor and by services registering scoped resolvers.
authzResolverRegistry := authz.NewResolverRegistry()

// Create new server for grpc & http. Also will support in process grpc potentially too
logger.Debug("initializing opentdf server")
cfg.Server.WellKnownConfigRegister = wellknown.RegisterConfiguration
otdf, err := server.NewOpenTDFServer(cfg.Server, logger, cacheManager)
otdf, err := server.NewOpenTDFServer(cfg.Server, logger, cacheManager, server.WithAuthzResolverRegistry(authzResolverRegistry))
if err != nil {
logger.Error("issue creating opentdf server", slog.String("error", err.Error()))
return fmt.Errorf("issue creating opentdf server: %w", err)
Expand Down Expand Up @@ -271,10 +275,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")
gatewayCleanup, err := startServices(ctx, startServicesParams{
cfg: cfg,
Expand Down
Loading
Loading