VAULT-42603: SCIM guardrails for identity resources (#12626) (#12748)

* base

* unit tests

* group tests

* groups test

* entity test

* alias test and fix error code

* fix error message

* lint

---------

Co-authored-by: miagilepner <mia.epner@hashicorp.com>
Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com>
This commit is contained in:
Vault Automation 2026-03-05 05:02:20 -05:00 committed by GitHub
parent bc2aa7e8ec
commit b25410c747
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 115 additions and 9 deletions

View File

@ -192,3 +192,15 @@ func ToSDKGroups(groups []*Group) []*logical.Group {
}
return ret
}
func (g *Group) SCIMClientID() string {
return g.ScimClientID
}
func (e *Entity) SCIMClientID() string {
return e.ScimClientID
}
func (a *Alias) SCIMClientID() string {
return a.ScimClientID
}

View File

@ -279,6 +279,9 @@ func (i *IdentityStore) handleAliasCreate(ctx context.Context, canonicalID, name
return nil, err
}
if err := i.scimResourceCheck(ctx, &identity.Alias{ScimClientID: scimClientID}, "", true); err != nil {
return logical.ErrorResponse(err.Error()), logical.ErrPermissionDenied
}
var entity *identity.Entity
if canonicalID != "" {
entity, err = i.MemDBEntityByID(canonicalID, true)
@ -370,6 +373,9 @@ func (i *IdentityStore) handleAliasUpdate(ctx context.Context, canonicalID, name
return nil, nil
}
if err := i.scimResourceCheck(ctx, alias, alias.ScimClientID, false); err != nil {
return logical.ErrorResponse(err.Error()), logical.ErrPermissionDenied
}
alias.LastUpdateTime = timestamppb.Now()
// Get our current entity, which may be the same as the new one if the
@ -604,6 +610,11 @@ func (i *IdentityStore) pathAliasIDDelete() framework.OperationFunc {
return logical.ErrorResponse("request and alias are in different namespaces"), logical.ErrPermissionDenied
}
scimClientID := scimClientIDFromContext(ctx)
if alias.ScimClientID != scimClientID {
return logical.ErrorResponse("SCIM-managed resources must be modified through SCIM"), logical.ErrPermissionDenied
}
// Fetch the associated entity
entity, err := i.MemDBEntityByAliasIDInTxn(txn, alias.ID, true)
if err != nil {

View File

@ -597,7 +597,10 @@ func (i *IdentityStore) handleEntityDeleteCommon(ctx context.Context, txn *memdb
if entity.NamespaceID != ns.ID {
return nil
}
scimClientID := scimClientIDFromContext(ctx)
if entity.ScimClientID != scimClientID {
return errors.New("SCIM-managed resources must be modified through SCIM")
}
// Remove entity ID as a member from all the groups it belongs, both
// internal and external
groups, err := i.MemDBGroupsByMemberEntityIDInTxn(txn, entity.ID, true, false)

View File

@ -15,10 +15,11 @@ import (
// EntityBuilder is used to construct or update an identity.Entity.
type EntityBuilder struct {
store *IdentityStore
entity *identity.Entity
isNew bool
err error
store *IdentityStore
entity *identity.Entity
isNew bool
originalSCIMID string
err error
}
// NewEntityBuilder creates a new builder instance.
@ -57,6 +58,7 @@ func (b *EntityBuilder) WithID(id string) *EntityBuilder {
}
b.entity = entity
b.originalSCIMID = b.entity.ScimClientID
b.isNew = false
return b
}
@ -76,6 +78,7 @@ func (b *EntityBuilder) WithExternalID(ctx context.Context, externalID string) *
if entityByExternalID != nil {
// An entity with this external ID already exists, so we'll update it.
b.entity = entityByExternalID
b.originalSCIMID = b.entity.ScimClientID
b.isNew = false
} else {
// No entity found, so we're just setting the external ID on the current one.
@ -103,6 +106,7 @@ func (b *EntityBuilder) WithName(ctx context.Context, name string) *EntityBuilde
case b.isNew:
// We haven't loaded an entity yet, but one with this name exists. Let's update it.
b.entity = entityByName
b.originalSCIMID = b.entity.ScimClientID
b.isNew = false
case b.entity.ID == entityByName.ID:
// The loaded entity and the one found by name are the same. No-op.
@ -167,6 +171,10 @@ func (b *EntityBuilder) Build(ctx context.Context) (*logical.Response, error) {
return logical.ErrorResponse(b.err.Error()), nil
}
if err := b.store.scimResourceCheck(ctx, b.entity, b.originalSCIMID, b.isNew); err != nil {
return logical.ErrorResponse(err.Error()), logical.ErrPermissionDenied
}
// Sanitize and persist the entity
if err := b.store.sanitizeEntity(ctx, b.entity); err != nil {
return nil, err

View File

@ -313,12 +313,15 @@ func (i *IdentityStore) handleGroupUpdateCommon(ctx context.Context, req *logica
group.Name = groupName
}
_, ok = d.Schema["scim_client_id"]
originalSCIMID := group.ScimClientID
scimClientID, ok := d.GetOk("scim_client_id")
if ok {
entitSCIMClientID := d.Get("scim_client_id").(string)
group.ScimClientID = entitSCIMClientID
group.ScimClientID = scimClientID.(string)
}
if err := i.scimResourceCheck(ctx, group, originalSCIMID, newGroup); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
metadata, ok, err := d.GetOkErr("metadata")
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("failed to parse metadata: %v", err)), nil
@ -525,6 +528,11 @@ func (i *IdentityStore) handleGroupDeleteCommon(ctx context.Context, key string,
return logical.ErrorResponse("request namespace is not the same as the group namespace"), logical.ErrPermissionDenied
}
scimID := scimClientIDFromContext(ctx)
if scimID != group.ScimClientID {
return logical.ErrorResponse("SCIM-managed resources must be modified through SCIM"), logical.ErrPermissionDenied
}
// Delete group alias from memdb
if group.Type == groupTypeExternal && group.Alias != nil {
err = i.MemDBDeleteAliasByIDInTxn(txn, group.Alias.ID, true)

View File

@ -3,7 +3,13 @@
package vault
import "github.com/hashicorp/go-memdb"
import (
"context"
"errors"
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/vault/helper/identity"
)
// SCIM client storage prefix
const scimClientStoragePrefix = "scim/client/"
@ -69,3 +75,61 @@ func scimClientSchema(_ bool) *memdb.TableSchema {
},
}
}
type scimClientRequest struct{}
func addSCIMClientIDToContext(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, scimClientRequest{}, id)
}
func scimClientIDFromContext(ctx context.Context) string {
val := ctx.Value(scimClientRequest{})
if val == nil {
return ""
}
return val.(string)
}
func (i *IdentityStore) scimResourceCheck(ctx context.Context, resource scimManaged, originalSCIMID string, isCreate bool) error {
reqSCIMClientID := scimClientIDFromContext(ctx)
resourceSCIMClientID := resource.SCIMClientID()
switch isCreate {
case true:
// The request must have come via a SCIM API in order to set
// the SCIM client ID
if reqSCIMClientID == "" && resourceSCIMClientID != "" {
return errors.New("cannot set scim_client_id")
}
if reqSCIMClientID != "" && resourceSCIMClientID == "" {
// this shouldn't ever happen
return errors.New("cannot create a resource via SCIM without a SCIM client ID")
}
if reqSCIMClientID != resourceSCIMClientID {
// this also shouldn't ever happen
return errors.New("cannot create resource via SCIM with a different SCIM client ID")
}
default:
// if the resource is being updated, the SCIM client ID
// cannot be modified
if originalSCIMID != resourceSCIMClientID {
return errors.New("cannot update scim_client_id")
}
// if the resource is being updated, this must be via SCIM
if originalSCIMID != reqSCIMClientID {
return errors.New("SCIM-managed resources must be modified through SCIM")
}
}
return nil
}
type scimManaged interface {
SCIMClientID() string
}
var (
_ scimManaged = (*identity.Entity)(nil)
_ scimManaged = (*identity.Group)(nil)
_ scimManaged = (*identity.Alias)(nil)
)