Skip to content
Closed
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
565 changes: 360 additions & 205 deletions helper/identity/types.pb.go

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions helper/identity/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ message Group {
// group.
// @inject_tag: sentinel:"-"
string namespace_id = 13;

// ScimClientId records the unique identifier of the SCIM client that
// originally created this Group. This establishes a strict ownership model,
// ensuring that authoritative lifecycle operations (like updates and deletes by
// an IGA) can only be performed by the client that owns the resource.
// @inject_tag: sentinel:"-"
string scim_client_id = 14;
}

// LocalAliases holds the aliases belonging to an entity that are local to the
Expand Down Expand Up @@ -155,6 +162,21 @@ message Entity {
// entity.
// @inject_tag: sentinel:"-"
string namespace_id = 12;

// ExternalId stores the `externalId` provided by a SCIM client.
// This field serves as the canonical correlation key, linking this Vault Entity
// to its representation in the external identity management system. It is
// crucial for preventing the creation of duplicate entities when multiple
// SCIM clients provision the same user.
// @inject_tag: sentinel:"-"
string external_id = 13;

// ScimClientId records the unique identifier of the SCIM client that
// originally created this Entity. This establishes a strict ownership model,
// ensuring that authoritative lifecycle operations (like updates and deletes by
// an IGA) can only be performed by the client that owns the resource.
// @inject_tag: sentinel:"-"
string scim_client_id = 14;
}

// Alias represents the alias that gets stored inside of the
Expand Down Expand Up @@ -233,6 +255,36 @@ message Alias {
// during invalidation of local aliases in performance standbys.
// @inject_tag: sentinel:"-"
string local_bucket_key = 14;

// ScimClientId records the unique identifier of the SCIM client that
// originally created this Alias. This establishes a strict ownership model,
// ensuring that authoritative lifecycle operations (like updates and deletes by
// an IGA) can only be performed by the client that owns the resource.
// @inject_tag: sentinel:"-"
string scim_client_id = 15;
}

// ScimConfig defines the stored configuration for a single SCIM client.
// This configuration links a client's identity within Vault to its specific
// role and capabilities within the SCIM server.
message ScimConfig {
// ClientId is a unique, user-defined identifier for this specific SCIM
// client configuration (e.g., 'Okta-Prod', 'SailPoint-Dev').
string client_id = 1;

// ClientRole defines the client's function and authoritative power.
// It must be either "IGA" (authoritative) or "IdP" (standard).
string client_role = 2;

// AccessGrantPrincipal is the Vault Entity ID that represents the SCIM
// client application itself. This is the principal that will be granted the
// necessary permissions to perform SCIM operations.
string access_grant_principal = 3;

// AliasMountAccessor is an optional field that specifies the mount accessor
// of an auth method where login aliases should be created for provisioned users.
// This is typically used for clients with the 'IdP' role.
string alias_mount_accessor = 4;
}

// Deprecated. Retained for backwards compatibility.
Expand Down
94 changes: 53 additions & 41 deletions vault/identity_store_aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,7 @@ func aliasPaths(i *IdentityStore) []*framework.Path {
OperationSuffix: "alias",
},

Fields: map[string]*framework.FieldSchema{
"id": {
Type: framework.TypeString,
Description: "ID of the entity alias. If set, updates the corresponding entity alias.",
},
// entity_id is deprecated in favor of canonical_id
"entity_id": {
Type: framework.TypeString,
Description: `Entity ID to which this alias belongs.
This field is deprecated, use canonical_id.`,
},
"canonical_id": {
Type: framework.TypeString,
Description: "Entity ID to which this alias belongs",
},
"mount_accessor": {
Type: framework.TypeString,
Description: "Mount accessor to which this alias belongs to; unused for a modify",
},
"name": {
Type: framework.TypeString,
Description: "Name of the alias; unused for a modify",
},
"custom_metadata": {
Type: framework.TypeKVPairs,
Description: "User provided key-value pairs",
},
},
Fields: aliasFieldSchema(),
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: i.handleAliasCreateUpdate(),
},
Expand Down Expand Up @@ -150,6 +123,37 @@ This field is deprecated, use canonical_id.`,
}
}

func aliasFieldSchema() map[string]*framework.FieldSchema {
return map[string]*framework.FieldSchema{
"id": {
Type: framework.TypeString,
Description: "ID of the entity alias. If set, updates the corresponding entity alias.",
},
// entity_id is deprecated in favor of canonical_id
"entity_id": {
Type: framework.TypeString,
Description: `Entity ID to which this alias belongs.
This field is deprecated, use canonical_id.`,
},
"canonical_id": {
Type: framework.TypeString,
Description: "Entity ID to which this alias belongs",
},
"mount_accessor": {
Type: framework.TypeString,
Description: "Mount accessor to which this alias belongs to; unused for a modify",
},
"name": {
Type: framework.TypeString,
Description: "Name of the alias; unused for a modify",
},
"custom_metadata": {
Type: framework.TypeKVPairs,
Description: "User provided key-value pairs",
},
}
}

// handleAliasCreateUpdate is used to create or update an alias
func (i *IdentityStore) handleAliasCreateUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
Expand Down Expand Up @@ -239,30 +243,37 @@ func (i *IdentityStore) handleAliasCreateUpdate() framework.OperationFunc {
return logical.ErrorResponse("'id' or 'mount_accessor' and 'name' must be provided"), nil
}

// Look up the alias by factors; if it's found it's an update
mountEntry := i.router.MatchingMountByAccessor(mountAccessor)
if mountEntry == nil {
return logical.ErrorResponse(fmt.Sprintf("invalid mount accessor %q", mountAccessor)), nil
}
if mountEntry.NamespaceID != ns.ID {
return logical.ErrorResponse("matching mount is in a different namespace than request"), logical.ErrPermissionDenied
}
alias, err := i.MemDBAliasByFactors(mountAccessor, name, true, false)
if err != nil {
return nil, err
}
if alias != nil {
if alias.NamespaceID != ns.ID {
return logical.ErrorResponse("cannot modify aliases across namespaces"), logical.ErrPermissionDenied
}
return i.handleAliasUpdate(ctx, canonicalID, name, mountAccessor, alias, customMetadata)

localMount := mountEntry.Local

// Look up the alias by factors; if it's found it's an update
return i.handleAliasCreateUpdateCommon(ctx, ns, mountAccessor, name, canonicalID, customMetadata, localMount, "")
}
}

func (i *IdentityStore) handleAliasCreateUpdateCommon(ctx context.Context, ns *namespace.Namespace, mountAccessor string, name string, canonicalID string, customMetadata map[string]string, localMount bool, scimClientID string) (*logical.Response, error) {
alias, err := i.MemDBAliasByFactors(mountAccessor, name, true, false)
if err != nil {
return nil, err
}
if alias != nil {
if alias.NamespaceID != ns.ID {
return logical.ErrorResponse("cannot modify aliases across namespaces"), logical.ErrPermissionDenied
}
// At this point we know it's a new creation request
return i.handleAliasCreate(ctx, canonicalID, name, mountAccessor, mountEntry.Local, customMetadata)
return i.handleAliasUpdate(ctx, canonicalID, name, mountAccessor, alias, customMetadata)
}
// At this point we know it's a new creation request
return i.handleAliasCreate(ctx, canonicalID, name, mountAccessor, localMount, customMetadata, scimClientID)
}

func (i *IdentityStore) handleAliasCreate(ctx context.Context, canonicalID, name, mountAccessor string, local bool, customMetadata map[string]string) (*logical.Response, error) {
func (i *IdentityStore) handleAliasCreate(ctx context.Context, canonicalID, name, mountAccessor string, local bool, customMetadata map[string]string, scimClientID string) (*logical.Response, error) {
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
Expand Down Expand Up @@ -326,6 +337,7 @@ func (i *IdentityStore) handleAliasCreate(ctx context.Context, canonicalID, name
Name: name,
CustomMetadata: customMetadata,
CanonicalID: entity.ID,
ScimClientID: scimClientID,
}
err = i.sanitizeAlias(ctx, alias)
if err != nil {
Expand Down
109 changes: 9 additions & 100 deletions vault/identity_store_entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ func entityPathFields() map[string]*framework.FieldSchema {
Type: framework.TypeString,
Description: "Name of the entity",
},
"external_id": {
Type: framework.TypeString,
Description: "External ID of the entity",
},
"scim_client_id": {
Type: framework.TypeString,
Description: "SCIM Client ID of the entity",
},
"metadata": {
Type: framework.TypeKVPairs,
Description: `Metadata to be associated with the entity.
Expand Down Expand Up @@ -304,106 +312,7 @@ func (i *IdentityStore) handleEntityUpdateCommon() framework.OperationFunc {
i.lock.Lock()
defer i.lock.Unlock()

entity := new(identity.Entity)
var err error

entityID := d.Get("id").(string)
if entityID != "" {
entity, err = i.MemDBEntityByID(entityID, true)
if err != nil {
return nil, err
}
if entity == nil {
return logical.ErrorResponse("entity not found from id"), nil
}
}

// Get the name
entityName := d.Get("name").(string)
if entityName != "" {
entityByName, err := i.MemDBEntityByName(ctx, entityName, true)
if err != nil {
return nil, err
}
switch {
case entityByName == nil:
// Not found, safe to use this name with an existing or new entity
case entity.ID == "":
// Entity by ID was not found, but and entity for the supplied
// name was found. Continue updating the entity.
entity = entityByName
case entity.ID == entityByName.ID:
// Same exact entity, carry on (this is basically a noop then)
default:
return logical.ErrorResponse("entity name is already in use"), nil
}
}

if entityName != "" {
entity.Name = entityName
}

// Update the policies if supplied
entityPoliciesRaw, ok := d.GetOk("policies")
if ok {
entity.Policies = strutil.RemoveDuplicates(entityPoliciesRaw.([]string), false)
}

if strutil.StrListContainsCaseInsensitive(entity.Policies, "root") {
return logical.ErrorResponse("policies cannot contain root"), nil
}

disabledRaw, ok := d.GetOk("disabled")
if ok {
entity.Disabled = disabledRaw.(bool)
}

// Get entity metadata
metadata, ok, err := d.GetOkErr("metadata")
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("failed to parse metadata: %v", err)), nil
}
if ok {
entity.Metadata = metadata.(map[string]string)
}

// At this point, if entity.ID is empty, it indicates that a new entity
// is being created. Using this to respond data in the response.
newEntity := entity.ID == ""

// ID creation and some validations
err = i.sanitizeEntity(ctx, entity)
if err != nil {
return nil, err
}

if err := i.upsertEntity(ctx, entity, nil, true); err != nil {
return nil, err
}

// If this operation was an update to an existing entity, return 204
if !newEntity {
return nil, nil
}

// Prepare the response
respData := map[string]interface{}{
"id": entity.ID,
"name": entity.Name,
}

var aliasIDs []string
for _, alias := range entity.Aliases {
aliasIDs = append(aliasIDs, alias.ID)
}

respData["aliases"] = aliasIDs

// Return ID of the entity that was either created or updated along with
// its aliases
return &logical.Response{
Data: respData,
}, nil
return i.EntityUpdateCommon(ctx, d)
}
}

Expand Down
Loading
Loading