Allow oauth profile alias accessors (#13482) (#13548)

* identity: allow oauth profile alias accessors

Allow identity/entity-alias mount_accessor to use sys/config/oauth-resource-server/<profile> when the profile exists in the request namespace, while preserving existing mount accessor and namespace checks for real mounts.

Add focused identity alias tests for valid profile accessor acceptance and unknown profile rejection.



* identity: document alias accessor validation cases

Add GoDoc for validateAliasMountAccessor to clarify supported mount_accessor validation for auth-method aliases and OAuth/External JWT profile-style aliases.



* identity: use namespace+configid oauth alias accessor

Implement synthetic OAuth alias mount_accessor format as oauth_resource_server_<namespace_id>_<config_id> and validate by namespace and config ID for identity/entity-alias.

Add stable config_id to OAuth resource-server profiles, expose it on profile read responses, and add compatibility hydration for older stored profiles missing config_id.

Update identity alias tests for new accessor encoding and add cross-namespace rejection coverage.



* oauth: persist legacy profile config ids on read

Backfill missing OAuth Resource Server profile config_id under profile lock and persist it so config_id remains stable for synthetic identity alias accessors.

Update config-id lookup to resolve profiles through the read path so legacy entries are migrated before matching.

Add regression test covering legacy no-config_id profile migration and successful alias creation with migrated accessor.



* identity: clarify oauth profile existence check

Document that getOAuthResourceServerConfigProfileByConfigID is used only to verify the referenced OAuth profile exists during synthetic mount_accessor validation.



* oauth: add config-id index for O(1) lookup

Add profiles-by-config-id storage index and switch getOAuthResourceServerConfigProfileByConfigID to index-based resolution to avoid O(N) profile scans during alias accessor validation.

Persist index entries on profile upsert, clean them up on delete, and keep legacy config_id backfill path consistent with indexed storage.

Add regression tests for indexed lookup, missing-index behavior, and index cleanup on delete.



* vault: isolate oauth alias validation by build tag



* vault: move oauth accessor constants to enterprise file



* vault: tighten alias accessor validation returns



* vault: require oauth profile config_id on read



* vault: redact oauth profile identifiers in logs



* vault: remove oauth profile identifiers from logs



* vault: harden oauth log redaction paths



* vault: fix oauth invalidation replicated-path test fixture



* vault: remove sensitive error payloads from oauth logs



* Address PR review feedback for logging and tests

- restore operational error logging in OAuth invalidation/read/delete paths

- improve nil synthetic alias validator diagnostics with explicit log + internal error

- move config_id index tests from core-based vault tests to external NewTestCluster tests

- export GetOAuthResourceServerConfigProfileByConfigID for external coverage



* Apply review feedback for alias validator nil case

- include mount_accessor context in operational log when synthetic validator is nil

- return accessor-specific internal configuration error for easier troubleshooting



* Consolidate OAuth config_id tests into existing storage test file

- move config_id index coverage into oauth_resource_storage_ent_test.go

- remove standalone oauth_resource_config_id_index_ent_test.go



* Apply review nit for accessor prefix constant

- trim oauthResourceServerAliasAccessorPrefix to remove trailing underscore

- build synthetic accessor using explicit separator concatenation



* tests: migrate oauth alias accessor coverage to external



* identity: switch oauth synthetic accessor prefix to hyphenated



---------

Co-authored-by: Bianca <48203644+biazmoreira@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-04-10 03:36:15 -04:00 committed by GitHub
parent 28a1f595c5
commit b98004d1dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 42 deletions

View File

@ -67,22 +67,23 @@ func (i *IdentityStore) resetDB() error {
func NewIdentityStore(ctx context.Context, core *Core, config *logical.BackendConfig, logger log.Logger) (*IdentityStore, error) {
iStore := &IdentityStore{
view: config.StorageView,
logger: logger,
router: core.router,
redirectAddr: core.redirectAddr,
localNode: core,
namespacer: core,
metrics: core.MetricSink(),
totpPersister: core,
groupUpdater: core,
tokenStorer: core,
entityCreator: core,
mountLister: core,
mfaBackend: core.loginMFABackend,
aliasLocks: locksutil.CreateLocks(),
activationManager: core.FeatureActivationFlags,
activationErrorHandler: core,
view: config.StorageView,
logger: logger,
router: core.router,
redirectAddr: core.redirectAddr,
localNode: core,
namespacer: core,
metrics: core.MetricSink(),
totpPersister: core,
groupUpdater: core,
tokenStorer: core,
entityCreator: core,
mountLister: core,
syntheticAliasAccessorValidator: core,
mfaBackend: core.loginMFABackend,
aliasLocks: locksutil.CreateLocks(),
activationManager: core.FeatureActivationFlags,
activationErrorHandler: core,
}
// Create a memdb instance, which by default, operates on lower cased

View File

@ -136,6 +136,42 @@ This field is deprecated, use canonical_id.`,
}
}
// validateAliasMountAccessor validates mount_accessor values for entity aliases.
//
// It accepts either a real mounted backend accessor or a supported synthetic
// accessor validated by the synthetic alias accessor validator extension point.
//
// For mounted backend accessors, this returns the matched mount entry. For
// synthetic accessors, this returns a minimal entry carrying namespace/local
// semantics used by alias create/update checks.
func (i *IdentityStore) validateAliasMountAccessor(ctx context.Context, mountAccessor string) (*MountEntry, error) {
if mountAccessor == "" {
return nil, fmt.Errorf("invalid mount accessor %q", mountAccessor)
}
if mountEntry := i.router.MatchingMountByAccessor(mountAccessor); mountEntry != nil {
return mountEntry, nil
}
if i.syntheticAliasAccessorValidator == nil {
i.logger.Error("synthetic alias accessor validator is not configured", "mount_accessor", mountAccessor)
return nil, fmt.Errorf("failed to validate mount accessor %q due to internal configuration error", mountAccessor)
}
valid, err := i.syntheticAliasAccessorValidator.validateSyntheticAliasAccessor(ctx, mountAccessor)
if err != nil {
return nil, err
}
if !valid {
return nil, fmt.Errorf("invalid mount accessor %q", mountAccessor)
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
return &MountEntry{NamespaceID: ns.ID}, nil
}
func aliasFieldSchema() map[string]*framework.FieldSchema {
return map[string]*framework.FieldSchema{
"id": {
@ -284,15 +320,15 @@ func (i *IdentityStore) handleAliasCreateUpdate() framework.OperationFunc {
return logical.ErrorResponse("'id' or 'mount_accessor' and 'name' must be provided"), nil
}
mountEntry := i.router.MatchingMountByAccessor(mountAccessor)
if mountEntry == nil {
return logical.ErrorResponse(fmt.Sprintf("invalid mount accessor %q", mountAccessor)), nil
mountEntry, err := i.validateAliasMountAccessor(ctx, mountAccessor)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if mountEntry.NamespaceID != ns.ID {
if mountEntry != nil && mountEntry.NamespaceID != ns.ID {
return logical.ErrorResponse("matching mount is in a different namespace than request"), logical.ErrPermissionDenied
}
localMount := mountEntry.Local
localMount := mountEntry != nil && mountEntry.Local
// Look up the alias by factors; if it's found it's an update
return i.handleAliasCreateUpdateCommon(ctx, ns, mountAccessor, name, canonicalID, externalID, issuer, customMetadata, localMount, "")
@ -497,11 +533,11 @@ func (i *IdentityStore) handleAliasUpdate(ctx context.Context, canonicalID, name
!strutil.EqualStringMaps(customMetadata, alias.CustomMetadata) ||
issuer != alias.Issuer || externalID != alias.ExternalID {
// Check here to see if such an alias already exists, if so bail
mountEntry := i.router.MatchingMountByAccessor(mountAccessor)
if mountEntry == nil {
return logical.ErrorResponse(fmt.Sprintf("invalid mount accessor %q", mountAccessor)), nil
mountEntry, err := i.validateAliasMountAccessor(ctx, mountAccessor)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if mountEntry.NamespaceID != alias.NamespaceID {
if mountEntry != nil && mountEntry.NamespaceID != alias.NamespaceID {
return logical.ErrorResponse("given mount accessor is not in the same namespace as the existing alias"), logical.ErrPermissionDenied
}
@ -536,15 +572,16 @@ func (i *IdentityStore) handleAliasUpdate(ctx context.Context, canonicalID, name
alias.CustomMetadata = customMetadata
}
mountValidationResp := i.router.ValidateMountByAccessor(alias.MountAccessor)
if mountValidationResp == nil {
return nil, fmt.Errorf("invalid mount accessor %q", alias.MountAccessor)
mountEntry, err := i.validateAliasMountAccessor(ctx, alias.MountAccessor)
if err != nil {
return nil, err
}
mountIsLocal := mountEntry != nil && mountEntry.Local
newEntity := currentEntity
if canonicalID != "" && canonicalID != alias.CanonicalID {
// Don't allow moving local aliases between entities.
if mountValidationResp.MountLocal {
if mountIsLocal {
return logical.ErrorResponse("local aliases can't be moved between entities"), nil
}
@ -590,11 +627,11 @@ func (i *IdentityStore) handleAliasUpdate(ctx context.Context, canonicalID, name
currentEntity = nil
}
if mountValidationResp.MountLocal {
if mountIsLocal {
alias, err = i.processLocalAlias(ctx, &logical.Alias{
MountAccessor: mountAccessor,
Name: name,
Local: mountValidationResp.MountLocal,
Local: mountIsLocal,
CustomMetadata: customMetadata,
Issuer: issuer,
ExternalID: externalID,

View File

@ -0,0 +1,12 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build !enterprise
package vault
import "context"
func (c *Core) validateSyntheticAliasAccessor(context.Context, string) (bool, error) {
return false, nil
}

View File

@ -104,17 +104,18 @@ type IdentityStore struct {
// operated case insensitively
disableLowerCasedNames bool
router *Router
redirectAddr string
localNode LocalNode
namespacer Namespacer
metrics metricsutil.Metrics
totpPersister TOTPPersister
groupUpdater GroupUpdater
tokenStorer TokenStorer
entityCreator EntityCreator
mountLister MountLister
mfaBackend *LoginMFABackend
router *Router
redirectAddr string
localNode LocalNode
namespacer Namespacer
metrics metricsutil.Metrics
totpPersister TOTPPersister
groupUpdater GroupUpdater
tokenStorer TokenStorer
entityCreator EntityCreator
mountLister MountLister
syntheticAliasAccessorValidator SyntheticAliasAccessorValidator
mfaBackend *LoginMFABackend
// aliasLocks is used to protect modifications to alias entries based on the uniqueness factor
// which is name + accessor
@ -196,6 +197,12 @@ type MountLister interface {
var _ MountLister = &Core{}
type SyntheticAliasAccessorValidator interface {
validateSyntheticAliasAccessor(context.Context, string) (bool, error)
}
var _ SyntheticAliasAccessorValidator = &Core{}
type Sealer interface {
Shutdown() error
}