Skip to content
60 changes: 59 additions & 1 deletion docs/Configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ The platform leverages [viper](https://github.com/spf13/viper) to help load conf
- [Configuration in opentdf-example.yaml](#configuration-in-opentdf-exampleyaml)
- [Role Permissions](#role-permissions)
- [Managing Authorization Policy](#managing-authorization-policy)
- [Cache Configuration](#cache-configuration)
- [Cache Configuration](#cache-configuration)
- [Secrets In Config](#secrets-in-config)

## Deployment Mode

Expand Down Expand Up @@ -305,6 +306,63 @@ Root level key `policy`
| `list_request_limit_default` | Policy List request limit default when not provided | 1000 | OPENTDF_SERVICES_POLICY_LIST_REQUEST_LIMIT_DEFAULT |
| `list_request_limit_max` | Policy List request limit maximum enforced by services | 2500 | OPENTDF_SERVICES_POLICY_LIST_REQUEST_LIMIT_MAX |

## Secrets In Config

Some service configuration fields are sensitive (secrets). The platform supports convenient, safe ways to provide these values in YAML without leaking them in logs:

- Literal value: use `literal:` prefix
- Environment variable reference: use `env:` prefix
- File reference (e.g., mounted secret): use `file:` prefix

Examples:

```yaml
services:
kas:
# Provide a root key via environment variable
root_key: "env:OPENTDF_SERVICES_KAS_ROOT_KEY"

# Or as a literal (avoid in production)
# root_key: "literal:493ff7acd07b..."

# Or from a file path (e.g., k8s secret volume)
# root_key: "file:/var/run/secrets/opentdf/kas_root_key"

preview:
key_management: true
```

Notes:
- Secrets are redacted in logs and JSON output; only a redacted summary is shown.
- For nested secret fields in service-specific configs, prefer the inline `env:` form in YAML to avoid underscore-to-dot mapping issues with environment variables alone.
- You can still use a structured map form for references (YAML):

Block style (preferred for readability):
```yaml
services:
kas:
root_key:
fromEnv: OPENTDF_SERVICES_KAS_ROOT_KEY
```

Or referencing a file path:
```yaml
services:
kas:
root_key:
fromFile: /var/run/secrets/opentdf/kas_root_key
```

Flow style (compact):
```yaml
services:
kas:
root_key: { fromEnv: OPENTDF_SERVICES_KAS_ROOT_KEY }
# or
root_key: { fromFile: /var/run/secrets/opentdf/kas_root_key }
```


Example:

```yaml
Expand Down
2 changes: 1 addition & 1 deletion service/kas/access/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type KASConfig struct {
// Deprecated
RSACertID string `mapstructure:"rsacertid" json:"rsacertid"`

RootKey string `mapstructure:"root_key" json:"root_key"`
RootKey config.Secret `mapstructure:"root_key" json:"root_key"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even changes to 'experimental' features shouldn't break builds downstream. Deprecate instead of removing to avoid blocking the next release of downstream integrations.

Suggested change
RootKey config.Secret `mapstructure:"root_key" json:"root_key"`
// Deprecated: Use EnvelopeKey
RootKey string `mapstructure:"root_key" json:"root_key"`
EnvelopeKey config.Secret `mapstructure:"envelope_key" json:"envelope_key"`

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There shouldn't be downstream impact. This should still support a string literal in yaml without any prefix.


KeyCacheExpiration time.Duration `mapstructure:"key_cache_expiration" json:"key_cache_expiration"`

Expand Down
121 changes: 75 additions & 46 deletions service/kas/kas.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ package kas

import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/url"
"strings"

"github.com/go-viper/mapstructure/v2"
kaspb "github.com/opentdf/platform/protocol/go/kas"
"github.com/opentdf/platform/protocol/go/kas/kasconnect"
"github.com/opentdf/platform/service/internal/security"
Expand All @@ -21,9 +21,9 @@ import (
)

func OnConfigUpdate(p *access.Provider) serviceregistry.OnConfigUpdateHook {
return func(_ context.Context, cfg config.ServiceConfig) error {
return func(ctx context.Context, cfg config.ServiceConfig) error {
var kasCfg access.KASConfig
if err := mapstructure.Decode(cfg, &kasCfg); err != nil {
if err := config.BindServiceConfig(ctx, cfg, &kasCfg); err != nil {
return fmt.Errorf("invalid kas cfg [%v] %w", cfg, err)
}

Expand All @@ -46,10 +46,9 @@ func NewRegistration() *serviceregistry.Service[kasconnect.AccessServiceHandler]
OnConfigUpdate: onConfigUpdate,
RegisterFunc: func(srp serviceregistry.RegistrationParams) (kasconnect.AccessServiceHandler, serviceregistry.HandlerServer) {
var kasCfg access.KASConfig
if err := mapstructure.Decode(srp.Config, &kasCfg); err != nil {
if err := config.BindServiceConfig(context.Background(), srp.Config, &kasCfg); err != nil {
panic(fmt.Errorf("invalid kas cfg [%v] %w", srp.Config, err))
}

var cacheClient *cache.Cache
if kasCfg.KeyCacheExpiration != 0 {
var err error
Expand All @@ -61,54 +60,20 @@ func NewRegistration() *serviceregistry.Service[kasconnect.AccessServiceHandler]
}
}

var kmgrNames []string

if kasCfg.Preview.KeyManagement {
srp.Logger.Info("preview feature: key management is enabled")

kasURL, err := determineKASURL(srp, kasCfg)
if err != nil {
panic(fmt.Errorf("failed to determine KAS URL: %w", err))
if err := handleKeyManagement(srp, kasCfg, p, cacheClient); err != nil {
panic(err)
}

srp.Logger.Debug("determined KAS URL", slog.String("kas_url", kasURL.String()))

// Configure new delegation service
p.KeyDelegator = trust.NewDelegatingKeyService(NewPlatformKeyIndexer(srp.SDK, kasURL.String(), srp.Logger), srp.Logger, cacheClient)
// Track registered manager names for logging
for _, manager := range srp.KeyManagerFactories {
p.KeyDelegator.RegisterKeyManager(manager.Name, manager.Factory)
kmgrNames = append(kmgrNames, manager.Name)
}

// Register Basic Key Manager
p.KeyDelegator.RegisterKeyManager(security.BasicManagerName, func(opts *trust.KeyManagerFactoryOptions) (trust.KeyManager, error) {
bm, err := security.NewBasicManager(opts.Logger, opts.Cache, kasCfg.RootKey)
if err != nil {
return nil, err
}
return bm, nil
})
kmgrNames = append(kmgrNames, security.BasicManagerName)
// Explicitly set the default manager for session key generation.
// This should be configurable, e.g., defaulting to BasicManager or an HSM if available.
p.KeyDelegator.SetDefaultMode(security.BasicManagerName) // Example: default to BasicManager
} else {
// Set up both the legacy CryptoProvider and the new SecurityProvider
kasCfg.UpgradeMapToKeyring(srp.OTDF.CryptoProvider)
p.CryptoProvider = srp.OTDF.CryptoProvider

inProcessService := initSecurityProviderAdapter(p.CryptoProvider, kasCfg, srp.Logger)

p.KeyDelegator = trust.NewDelegatingKeyService(inProcessService, srp.Logger, nil)
p.KeyDelegator.RegisterKeyManager(inProcessService.Name(), func(*trust.KeyManagerFactoryOptions) (trust.KeyManager, error) {
return inProcessService, nil
})
// Set default for non-key-management mode
p.KeyDelegator.SetDefaultMode(inProcessService.Name())
kmgrNames = append(kmgrNames, inProcessService.Name())
err := handleLegacyMode(srp, kasCfg, p)
if err != nil {
panic(err)
}
}
srp.Logger.Info("kas registered trust.KeyManagers", slog.Any("key_managers", kmgrNames))

p.SDK = srp.SDK
p.Logger = srp.Logger
p.KASConfig = kasCfg
Expand Down Expand Up @@ -181,6 +146,70 @@ func determineKASURL(srp serviceregistry.RegistrationParams, kasCfg access.KASCo
return kasURL, nil
}

func handleKeyManagement(srp serviceregistry.RegistrationParams, kasCfg access.KASConfig, p *access.Provider, cacheClient *cache.Cache) error {
srp.Logger.Info("preview feature: key management is enabled")
srp.Logger.Debug("kas preview settings", slog.Any("preview", kasCfg.Preview))

kasURL, err := determineKASURL(srp, kasCfg)
if err != nil {
return fmt.Errorf("failed to determine KAS URL: %w", err)
}
srp.Logger.Debug("determined KAS URL", slog.String("kas_url", kasURL.String()))

var kmgrNames []string

// Configure new delegation service
p.KeyDelegator = trust.NewDelegatingKeyService(NewPlatformKeyIndexer(srp.SDK, kasURL.String(), srp.Logger), srp.Logger, cacheClient)
for _, manager := range srp.KeyManagerFactories {
p.KeyDelegator.RegisterKeyManager(manager.Name, manager.Factory)
kmgrNames = append(kmgrNames, manager.Name)
}
kmgrNames = append(kmgrNames, security.BasicManagerName)

// Register Basic Key Manager
p.KeyDelegator.RegisterKeyManager(security.BasicManagerName, func(opts *trust.KeyManagerFactoryOptions) (trust.KeyManager, error) {
// RootKey is required when key management is enabled.
if kasCfg.RootKey.IsZero() {
return nil, errors.New("root_key is required when preview.key_management is enabled; set OPENTDF_SERVICES_KAS_ROOT_KEY or services.kas.root_key")
}
rk, err := kasCfg.RootKey.Resolve(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve root_key: %w", err)
}
bm, err := security.NewBasicManager(opts.Logger, opts.Cache, rk)
if err != nil {
return nil, err
}
return bm, nil
})

srp.Logger.Info("kas registered trust.KeyManagers", slog.Any("key_managers", kmgrNames))

// Explicitly set the default manager for session key generation.
// This should be configurable, e.g., defaulting to BasicManager or an HSM if available.
p.KeyDelegator.SetDefaultMode(security.BasicManagerName) // Example: default to BasicManager
return nil
}

func handleLegacyMode(srp serviceregistry.RegistrationParams, kasCfg access.KASConfig, p *access.Provider) error { //nolint:unparam // maintains a consistent signature with other handlers
// Set up both the legacy CryptoProvider and the new SecurityProvider
kasCfg.UpgradeMapToKeyring(srp.OTDF.CryptoProvider)
p.CryptoProvider = srp.OTDF.CryptoProvider

inProcessService := initSecurityProviderAdapter(p.CryptoProvider, kasCfg, srp.Logger)

p.KeyDelegator = trust.NewDelegatingKeyService(inProcessService, srp.Logger, nil)
p.KeyDelegator.RegisterKeyManager(inProcessService.Name(), func(*trust.KeyManagerFactoryOptions) (trust.KeyManager, error) {
return inProcessService, nil
})

srp.Logger.Info("kas registered trust.KeyManagers", slog.Any("key_managers", []string{inProcessService.Name()}))

// Set default for non-key-management mode
p.KeyDelegator.SetDefaultMode(inProcessService.Name())
return nil
}

func initSecurityProviderAdapter(cryptoProvider *security.StandardCrypto, kasCfg access.KASConfig, l *logger.Logger) trust.KeyService {
var defaults []string
var legacies []string
Expand Down
81 changes: 81 additions & 0 deletions service/pkg/config/bind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package config

import (
"context"
"errors"
"fmt"

"github.com/go-playground/validator/v10"
"github.com/go-viper/mapstructure/v2"
)

// BindOptions controls how service config binding behaves.
type BindOptions struct {
// Eagerly resolve secrets during binding; otherwise they resolve lazily.
EagerResolve bool
}

// BindOption functional option.
type BindOption func(*BindOptions)

// WithEagerSecretResolution enables eager secret resolution during bind.
func WithEagerSecretResolution() BindOption { return func(o *BindOptions) { o.EagerResolve = true } }

// BindServiceConfig decodes a ServiceConfig map into a typed struct target using
// mapstructure with custom decode hooks (e.g., Secret). It optionally validates
// the result and can eagerly resolve secret values.
func BindServiceConfig[T any](ctx context.Context, svcCfg ServiceConfig, out *T, opts ...BindOption) error {
var options BindOptions
for _, o := range opts {
o(&options)
}

if out == nil {
return errors.New("nil output target")
}

dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(secretDecodeHook),
Result: out,
TagName: "mapstructure",
Squash: true,
ZeroFields: true,
})
if err != nil {
return fmt.Errorf("bind decoder: %w", err)
}
if err := dec.Decode(svcCfg); err != nil {
return fmt.Errorf("bind decode: %w", err)
}

// Eager secret resolution
if options.EagerResolve {
if err := resolveSecrets(ctx, out); err != nil {
return err
}
}

// Validate struct using go-playground/validator tags, if present
if err := validator.New().Struct(out); err != nil {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
return fmt.Errorf("validation failed: %s", verrs.Error())
}
return fmt.Errorf("validation failed: %w", err)
}
return nil
}

// resolveSecrets walks the struct and resolves any Secret fields.
func resolveSecrets(ctx context.Context, v any) error {
// Reflectively find Secret fields; keep it minimal and safe.
// We only walk exported fields of structs and slices/maps.
return walk(v, func(s *Secret) error {
// Skip zero-value secrets (unset optional fields)
if s.IsZero() {
return nil
}
_, err := s.Resolve(ctx)
return err
})
}
42 changes: 42 additions & 0 deletions service/pkg/config/bind_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type demoCfg struct {
User string `mapstructure:"user" validate:"required"`
Pass Secret `mapstructure:"pass"`
Nested struct {
Token Secret `mapstructure:"token"`
} `mapstructure:"nested"`
}

func TestBindServiceConfig_LiteralAndEnv(t *testing.T) {
t.Setenv("OPENTDF_DEMO_TOKEN", "tok")

in := ServiceConfig{
"user": "alice",
"pass": "p@ss",
"nested": map[string]any{
"token": map[string]any{"fromEnv": "OPENTDF_DEMO_TOKEN"},
},
}

var out demoCfg
err := BindServiceConfig(t.Context(), in, &out, WithEagerSecretResolution())
require.NoError(t, err)

assert.Equal(t, "alice", out.User)

pass, err := out.Pass.Resolve(t.Context())
require.NoError(t, err)
assert.Equal(t, "p@ss", pass)

tok, err := out.Nested.Token.Resolve(t.Context())
require.NoError(t, err)
assert.Equal(t, "tok", tok)
}
Loading
Loading