From 50a9c282406b4cc0d39ed8fe3cf5a4f3854a1bb9 Mon Sep 17 00:00:00 2001 From: vishalnayak Date: Wed, 19 Sep 2018 18:14:40 -0400 Subject: [PATCH 1/2] Support operating on entities and groups by their names --- vault/identity_store_entities.go | 331 ++++++++++------ vault/identity_store_entities_test.go | 155 ++++++-- vault/identity_store_groups.go | 374 +++++++++++------- vault/identity_store_groups_test.go | 116 ++++++ vault/identity_store_util.go | 12 +- .../source/api/secret/identity/entity.html.md | 152 +++++++ .../source/api/secret/identity/group.html.md | 160 ++++++++ 7 files changed, 997 insertions(+), 303 deletions(-) diff --git a/vault/identity_store_entities.go b/vault/identity_store_entities.go index 2711db0c8ae8..8dd175449941 100644 --- a/vault/identity_store_entities.go +++ b/vault/identity_store_entities.go @@ -17,6 +17,35 @@ import ( "github.com/hashicorp/vault/logical/framework" ) +func entityPathFields() map[string]*framework.FieldSchema { + return map[string]*framework.FieldSchema{ + "id": { + Type: framework.TypeString, + Description: "ID of the entity. If set, updates the corresponding existing entity.", + }, + "name": { + Type: framework.TypeString, + Description: "Name of the entity", + }, + "metadata": { + Type: framework.TypeKVPairs, + Description: `Metadata to be associated with the entity. +In CLI, this parameter can be repeated multiple times, and it all gets merged together. +For example: +vault metadata=key1=value1 metadata=key2=value2 + `, + }, + "policies": { + Type: framework.TypeCommaStringSlice, + Description: "Policies to be tied to the entity.", + }, + "disabled": { + Type: framework.TypeBool, + Description: "If set true, tokens tied to this identity will not be able to be used (but will not be revoked).", + }, + } +} + // entityPaths returns the API endpoints supported to operate on entities. // Following are the paths supported: // entity - To register a new entity @@ -26,32 +55,7 @@ func entityPaths(i *IdentityStore) []*framework.Path { return []*framework.Path{ { Pattern: "entity$", - Fields: map[string]*framework.FieldSchema{ - "id": { - Type: framework.TypeString, - Description: "ID of the entity. If set, updates the corresponding existing entity.", - }, - "name": { - Type: framework.TypeString, - Description: "Name of the entity", - }, - "metadata": { - Type: framework.TypeKVPairs, - Description: `Metadata to be associated with the entity. -In CLI, this parameter can be repeated multiple times, and it all gets merged together. -For example: -vault metadata=key1=value1 metadata=key2=value2 - `, - }, - "policies": { - Type: framework.TypeCommaStringSlice, - Description: "Policies to be tied to the entity.", - }, - "disabled": { - Type: framework.TypeBool, - Description: "If set true, tokens tied to this identity will not be able to be used (but will not be revoked).", - }, - }, + Fields: entityPathFields(), Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: i.handleEntityUpdateCommon(), }, @@ -60,33 +64,20 @@ vault metadata=key1=value1 metadata=key2=value2 HelpDescription: strings.TrimSpace(entityHelp["entity"][1]), }, { - Pattern: "entity/id/" + framework.GenericNameRegex("id"), - Fields: map[string]*framework.FieldSchema{ - "id": { - Type: framework.TypeString, - Description: "ID of the entity.", - }, - "name": { - Type: framework.TypeString, - Description: "Name of the entity.", - }, - "metadata": { - Type: framework.TypeKVPairs, - Description: `Metadata to be associated with the entity. -In CLI, this parameter can be repeated multiple times, and it all gets merged together. -For example: -vault metadata=key1=value1 metadata=key2=value2 - `, - }, - "policies": { - Type: framework.TypeCommaStringSlice, - Description: "Policies to be tied to the entity.", - }, - "disabled": { - Type: framework.TypeBool, - Description: "If set true, tokens tied to this identity will not be able to be used (but will not be revoked).", - }, + Pattern: "entity/name/" + framework.GenericNameRegex("name"), + Fields: entityPathFields(), + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: i.handleEntityUpdateCommon(), + logical.ReadOperation: i.pathEntityNameRead(), + logical.DeleteOperation: i.pathEntityNameDelete(), }, + + HelpSynopsis: strings.TrimSpace(entityHelp["entity-name"][0]), + HelpDescription: strings.TrimSpace(entityHelp["entity-name"][1]), + }, + { + Pattern: "entity/id/" + framework.GenericNameRegex("id"), + Fields: entityPathFields(), Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: i.handleEntityUpdateCommon(), logical.ReadOperation: i.pathEntityIDRead(), @@ -96,6 +87,15 @@ vault metadata=key1=value1 metadata=key2=value2 HelpSynopsis: strings.TrimSpace(entityHelp["entity-id"][0]), HelpDescription: strings.TrimSpace(entityHelp["entity-id"][1]), }, + { + Pattern: "entity/name/?$", + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: i.pathEntityNameList(), + }, + + HelpSynopsis: strings.TrimSpace(entityHelp["entity-name-list"][0]), + HelpDescription: strings.TrimSpace(entityHelp["entity-name-list"][1]), + }, { Pattern: "entity/id/?$", Callbacks: map[logical.Operation]framework.OperationFunc{ @@ -202,9 +202,9 @@ func (i *IdentityStore) handleEntityUpdateCommon() framework.OperationFunc { case entityByName == nil: // Not found, safe to use this name with an existing or new entity case entity.ID == "": - // We found an entity by name, but we don't currently allow - // updating based on name, only ID, so return an error - return logical.ErrorResponse("updating entity by name is not currently supported"), nil + // 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: @@ -239,12 +239,26 @@ func (i *IdentityStore) handleEntityUpdateCommon() framework.OperationFunc { 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, @@ -257,12 +271,6 @@ func (i *IdentityStore) handleEntityUpdateCommon() framework.OperationFunc { respData["aliases"] = aliasIDs - // Update MemDB and persist entity object. New entities have not been - // looked up yet so we need to take the lock on the entity on upsert - if err := i.upsertEntity(ctx, entity, nil, true); err != nil { - return nil, err - } - // Return ID of the entity that was either created or updated along with // its aliases return &logical.Response{ @@ -271,6 +279,26 @@ func (i *IdentityStore) handleEntityUpdateCommon() framework.OperationFunc { } } +// pathEntityNameRead returns the properties of an entity for a given entity ID +func (i *IdentityStore) pathEntityNameRead() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entityName := d.Get("name").(string) + if entityName == "" { + return logical.ErrorResponse("missing entity name"), nil + } + + entity, err := i.MemDBEntityByName(ctx, entityName, false) + if err != nil { + return nil, err + } + if entity == nil { + return nil, nil + } + + return i.handleEntityReadCommon(ctx, entity) + } +} + // pathEntityIDRead returns the properties of an entity for a given entity ID func (i *IdentityStore) pathEntityIDRead() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -423,78 +451,151 @@ func (i *IdentityStore) pathEntityIDDelete() framework.OperationFunc { } } -// pathEntityIDList lists the IDs of all the valid entities in the identity -// store -func (i *IdentityStore) pathEntityIDList() framework.OperationFunc { +// pathEntityNameDelete deletes the entity for a given entity ID +func (i *IdentityStore) pathEntityNameDelete() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entityName := d.Get("name").(string) + if entityName == "" { + return logical.ErrorResponse("missing entity name"), nil + } + + i.lock.Lock() + defer i.lock.Unlock() + + // Create a MemDB transaction to delete entity + txn := i.db.Txn(true) + defer txn.Abort() + + // Fetch the entity using its name + entity, err := i.MemDBEntityByNameInTxn(txn, ctx, entityName, true) + if err != nil { + return nil, err + } + // If there is no entity for the ID, do nothing + if entity == nil { + return nil, nil + } + ns, err := namespace.FromContext(ctx) if err != nil { return nil, err } + if entity.NamespaceID != ns.ID { + return nil, logical.ErrUnsupportedPath + } - ws := memdb.NewWatchSet() + // Delete all the aliases in the entity. This function will also remove + // the corresponding alias indexes too. + err = i.deleteAliasesInEntityInTxn(txn, entity, entity.Aliases) + if err != nil { + return nil, err + } - txn := i.db.Txn(false) + // Delete the entity using the same transaction + err = i.MemDBDeleteEntityByIDInTxn(txn, entity.ID) + if err != nil { + return nil, err + } - iter, err := txn.Get(entitiesTable, "namespace_id", ns.ID) + // Delete the entity from storage + err = i.entityPacker.DeleteItem(entity.ID) if err != nil { - return nil, errwrap.Wrapf("failed to fetch iterator for entities in memdb: {{err}}", err) + return nil, err } - ws.Add(iter.WatchCh()) + // Committing the transaction *after* successfully deleting entity + txn.Commit() - var entityIDs []string - entityInfo := map[string]interface{}{} + return nil, nil + } +} - type mountInfo struct { - MountType string - MountPath string - } - mountAccessorMap := map[string]mountInfo{} +func (i *IdentityStore) pathEntityIDList() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + return i.handlePathEntityListCommon(ctx, req, d, true) + } +} - for { - raw := iter.Next() - if raw == nil { - break - } - entity := raw.(*identity.Entity) - entityIDs = append(entityIDs, entity.ID) - entityInfoEntry := map[string]interface{}{ - "name": entity.Name, - } - if len(entity.Aliases) > 0 { - aliasList := make([]interface{}, 0, len(entity.Aliases)) - for _, alias := range entity.Aliases { - entry := map[string]interface{}{ - "id": alias.ID, - "name": alias.Name, - "mount_accessor": alias.MountAccessor, - } +func (i *IdentityStore) pathEntityNameList() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + return i.handlePathEntityListCommon(ctx, req, d, false) + } +} - mi, ok := mountAccessorMap[alias.MountAccessor] - if ok { +// handlePathEntityListCommon lists the IDs or names of all the valid entities +// in the identity store +func (i *IdentityStore) handlePathEntityListCommon(ctx context.Context, req *logical.Request, d *framework.FieldData, byID bool) (*logical.Response, error) { + ns, err := namespace.FromContext(ctx) + if err != nil { + return nil, err + } + + ws := memdb.NewWatchSet() + + txn := i.db.Txn(false) + + iter, err := txn.Get(entitiesTable, "namespace_id", ns.ID) + if err != nil { + return nil, errwrap.Wrapf("failed to fetch iterator for entities in memdb: {{err}}", err) + } + + ws.Add(iter.WatchCh()) + + var keys []string + entityInfo := map[string]interface{}{} + + type mountInfo struct { + MountType string + MountPath string + } + mountAccessorMap := map[string]mountInfo{} + + for { + raw := iter.Next() + if raw == nil { + break + } + entity := raw.(*identity.Entity) + if byID { + keys = append(keys, entity.ID) + } else { + keys = append(keys, entity.Name) + } + entityInfoEntry := map[string]interface{}{ + "name": entity.Name, + } + if len(entity.Aliases) > 0 { + aliasList := make([]interface{}, 0, len(entity.Aliases)) + for _, alias := range entity.Aliases { + entry := map[string]interface{}{ + "id": alias.ID, + "name": alias.Name, + "mount_accessor": alias.MountAccessor, + } + + mi, ok := mountAccessorMap[alias.MountAccessor] + if ok { + entry["mount_type"] = mi.MountType + entry["mount_path"] = mi.MountPath + } else { + mi = mountInfo{} + if mountValidationResp := i.core.router.validateMountByAccessor(alias.MountAccessor); mountValidationResp != nil { + mi.MountType = mountValidationResp.MountType + mi.MountPath = mountValidationResp.MountPath entry["mount_type"] = mi.MountType entry["mount_path"] = mi.MountPath - } else { - mi = mountInfo{} - if mountValidationResp := i.core.router.validateMountByAccessor(alias.MountAccessor); mountValidationResp != nil { - mi.MountType = mountValidationResp.MountType - mi.MountPath = mountValidationResp.MountPath - entry["mount_type"] = mi.MountType - entry["mount_path"] = mi.MountPath - } - mountAccessorMap[alias.MountAccessor] = mi } - - aliasList = append(aliasList, entry) + mountAccessorMap[alias.MountAccessor] = mi } - entityInfoEntry["aliases"] = aliasList + + aliasList = append(aliasList, entry) } - entityInfo[entity.ID] = entityInfoEntry + entityInfoEntry["aliases"] = aliasList } - - return logical.ListResponseWithInfo(entityIDs, entityInfo), nil + entityInfo[entity.ID] = entityInfoEntry } + + return logical.ListResponseWithInfo(keys, entityInfo), nil } func (i *IdentityStore) mergeEntity(ctx context.Context, txn *memdb.Txn, toEntity *identity.Entity, fromEntityIDs []string, force, grabLock, mergePolicies bool) (error, error) { @@ -637,10 +738,18 @@ var entityHelp = map[string][2]string{ "Update, read or delete an entity using entity ID", "", }, + "entity-name": { + "Update, read or delete an entity using entity name", + "", + }, "entity-id-list": { "List all the entity IDs", "", }, + "entity-name-list": { + "List all the entity names", + "", + }, "entity-merge-id": { "Merge two or more entities together", "", diff --git a/vault/identity_store_entities_test.go b/vault/identity_store_entities_test.go index 12f1336a06d4..0f72f3d696f7 100644 --- a/vault/identity_store_entities_test.go +++ b/vault/identity_store_entities_test.go @@ -14,6 +14,122 @@ import ( "github.com/hashicorp/vault/logical" ) +func TestIdentityStore_EntityByName(t *testing.T) { + ctx := namespace.RootContext(nil) + i, _, _ := testIdentityStoreWithGithubAuth(ctx, t) + + // Create an entity using the "name" endpoint + resp, err := i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + + // Test the read by name endpoint + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil || resp.Data["name"].(string) != "testentityname" { + t.Fatalf("bad entity response: %#v", resp) + } + + // Update entity metadata using the name endpoint + entityMetadata := map[string]string{ + "foo": "bar", + } + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.UpdateOperation, + Data: map[string]interface{}{ + "metadata": entityMetadata, + }, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + // Check the updated result + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil || !reflect.DeepEqual(resp.Data["metadata"].(map[string]string), entityMetadata) { + t.Fatalf("bad entity response: %#v", resp) + } + + // Delete the entity using the name endpoint + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.DeleteOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + // Check if deletion was successful + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp != nil { + t.Fatalf("expected a nil response") + } + + // Create 2 entities + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name/testentityname2", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + + // List the entities by name + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "entity/name", + Operation: logical.ListOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + expected := []string{"testentityname2", "testentityname"} + sort.Strings(expected) + actual := resp.Data["keys"].([]string) + sort.Strings(actual) + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: entity list response; expected: %#v\nactual: %#v", expected, actual) + } +} + func TestIdentityStore_EntityReadGroupIDs(t *testing.T) { var err error var resp *logical.Response @@ -154,8 +270,8 @@ func TestIdentityStore_EntityCreateUpdate(t *testing.T) { func TestIdentityStore_CloneImmutability(t *testing.T) { alias := &identity.Alias{ - ID: "testaliasid", - Name: "testaliasname", + ID: "testaliasid", + Name: "testaliasname", MergedFromCanonicalIDs: []string{"entityid1"}, } @@ -543,41 +659,6 @@ func TestIdentityStore_MemDBEntityIndexes(t *testing.T) { } -// This test is required because MemDB does not take care of ensuring -// uniqueness of indexes that are marked unique. It is the job of the higher -// level abstraction, the identity store in this case. -func TestIdentityStore_EntitySameEntityNames(t *testing.T) { - var err error - var resp *logical.Response - ctx := namespace.RootContext(nil) - is, _, _ := testIdentityStoreWithGithubAuth(ctx, t) - - registerData := map[string]interface{}{ - "name": "testentityname", - } - - registerReq := &logical.Request{ - Operation: logical.UpdateOperation, - Path: "entity", - Data: registerData, - } - - // Register an entity - resp, err = is.HandleRequest(ctx, registerReq) - if err != nil || (resp != nil && resp.IsError()) { - t.Fatalf("err:%v resp:%#v", err, resp) - } - - // Register another entity with same name - resp, err = is.HandleRequest(ctx, registerReq) - if err != nil { - t.Fatal(err) - } - if resp == nil || !resp.IsError() { - t.Fatalf("expected an error due to entity name not being unique") - } -} - func TestIdentityStore_EntityCRUD(t *testing.T) { var err error var resp *logical.Response diff --git a/vault/identity_store_groups.go b/vault/identity_store_groups.go index e695f11fbee8..70d273fdfd2a 100644 --- a/vault/identity_store_groups.go +++ b/vault/identity_store_groups.go @@ -19,44 +19,48 @@ const ( groupTypeExternal = "external" ) -func groupPaths(i *IdentityStore) []*framework.Path { - return []*framework.Path{ - { - Pattern: "group$", - Fields: map[string]*framework.FieldSchema{ - "id": { - Type: framework.TypeString, - Description: "ID of the group. If set, updates the corresponding existing group.", - }, - "type": { - Type: framework.TypeString, - Description: "Type of the group, 'internal' or 'external'. Defaults to 'internal'", - }, - "name": { - Type: framework.TypeString, - Description: "Name of the group.", - }, - "metadata": { - Type: framework.TypeKVPairs, - Description: `Metadata to be associated with the group. +func groupPathFields() map[string]*framework.FieldSchema { + return map[string]*framework.FieldSchema{ + "id": { + Type: framework.TypeString, + Description: "ID of the group. If set, updates the corresponding existing group.", + }, + "type": { + Type: framework.TypeString, + Description: "Type of the group, 'internal' or 'external'. Defaults to 'internal'", + }, + "name": { + Type: framework.TypeString, + Description: "Name of the group.", + }, + "metadata": { + Type: framework.TypeKVPairs, + Description: `Metadata to be associated with the group. In CLI, this parameter can be repeated multiple times, and it all gets merged together. For example: vault metadata=key1=value1 metadata=key2=value2 `, - }, - "policies": { - Type: framework.TypeCommaStringSlice, - Description: "Policies to be tied to the group.", - }, - "member_group_ids": { - Type: framework.TypeCommaStringSlice, - Description: "Group IDs to be assigned as group members.", - }, - "member_entity_ids": { - Type: framework.TypeCommaStringSlice, - Description: "Entity IDs to be assigned as group members.", - }, - }, + }, + "policies": { + Type: framework.TypeCommaStringSlice, + Description: "Policies to be tied to the group.", + }, + "member_group_ids": { + Type: framework.TypeCommaStringSlice, + Description: "Group IDs to be assigned as group members.", + }, + "member_entity_ids": { + Type: framework.TypeCommaStringSlice, + Description: "Entity IDs to be assigned as group members.", + }, + } +} + +func groupPaths(i *IdentityStore) []*framework.Path { + return []*framework.Path{ + { + Pattern: "group$", + Fields: groupPathFields(), Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: i.pathGroupRegister(), }, @@ -66,41 +70,7 @@ vault metadata=key1=value1 metadata=key2=value2 }, { Pattern: "group/id/" + framework.GenericNameRegex("id"), - Fields: map[string]*framework.FieldSchema{ - "id": { - Type: framework.TypeString, - Description: "ID of the group.", - }, - "type": { - Type: framework.TypeString, - Default: groupTypeInternal, - Description: "Type of the group, 'internal' or 'external'. Defaults to 'internal'", - }, - "name": { - Type: framework.TypeString, - Description: "Name of the group.", - }, - "metadata": { - Type: framework.TypeKVPairs, - Description: `Metadata to be associated with the group. -In CLI, this parameter can be repeated multiple times, and it all gets merged together. -For example: -vault metadata=key1=value1 metadata=key2=value2 - `, - }, - "policies": { - Type: framework.TypeCommaStringSlice, - Description: "Policies to be tied to the group.", - }, - "member_group_ids": { - Type: framework.TypeCommaStringSlice, - Description: "Group IDs to be assigned as group members.", - }, - "member_entity_ids": { - Type: framework.TypeCommaStringSlice, - Description: "Entity IDs to be assigned as group members.", - }, - }, + Fields: groupPathFields(), Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: i.pathGroupIDUpdate(), logical.ReadOperation: i.pathGroupIDRead(), @@ -119,6 +89,27 @@ vault metadata=key1=value1 metadata=key2=value2 HelpSynopsis: strings.TrimSpace(groupHelp["group-id-list"][0]), HelpDescription: strings.TrimSpace(groupHelp["group-id-list"][1]), }, + { + Pattern: "group/name/" + framework.GenericNameRegex("name"), + Fields: groupPathFields(), + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: i.pathGroupNameUpdate(), + logical.ReadOperation: i.pathGroupNameRead(), + logical.DeleteOperation: i.pathGroupNameDelete(), + }, + + HelpSynopsis: strings.TrimSpace(groupHelp["group-by-name"][0]), + HelpDescription: strings.TrimSpace(groupHelp["group-by-name"][1]), + }, + { + Pattern: "group/name/?$", + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: i.pathGroupNameList(), + }, + + HelpSynopsis: strings.TrimSpace(groupHelp["group-name-list"][0]), + HelpDescription: strings.TrimSpace(groupHelp["group-name-list"][1]), + }, } } @@ -158,6 +149,24 @@ func (i *IdentityStore) pathGroupIDUpdate() framework.OperationFunc { } } +func (i *IdentityStore) pathGroupNameUpdate() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + groupName := d.Get("name").(string) + if groupName == "" { + return logical.ErrorResponse("empty group name"), nil + } + + i.groupLock.Lock() + defer i.groupLock.Unlock() + + group, err := i.MemDBGroupByName(ctx, groupName, true) + if err != nil { + return nil, err + } + return i.handleGroupUpdateCommon(ctx, req, d, group) + } +} + func (i *IdentityStore) handleGroupUpdateCommon(ctx context.Context, req *logical.Request, d *framework.FieldData, group *identity.Group) (*logical.Response, error) { var newGroup bool if group == nil { @@ -210,8 +219,8 @@ func (i *IdentityStore) handleGroupUpdateCommon(ctx context.Context, req *logica switch { case groupByName == nil: // Allowed - case newGroup: - return logical.ErrorResponse("updating a group by name is not currently supported"), nil + case group.ID == "": + group = groupByName case group.ID != "" && groupByName.ID != group.ID: return logical.ErrorResponse("group name is already in use"), nil } @@ -251,6 +260,10 @@ func (i *IdentityStore) handleGroupUpdateCommon(ctx context.Context, req *logica return nil, err } + if !newGroup { + return nil, nil + } + respData := map[string]interface{}{ "id": group.ID, "name": group.Name, @@ -279,6 +292,25 @@ func (i *IdentityStore) pathGroupIDRead() framework.OperationFunc { } } +func (i *IdentityStore) pathGroupNameRead() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + groupName := d.Get("name").(string) + if groupName == "" { + return logical.ErrorResponse("empty group name"), nil + } + + group, err := i.MemDBGroupByName(ctx, groupName, false) + if err != nil { + return nil, err + } + if group == nil { + return nil, nil + } + + return i.handleGroupReadCommon(ctx, group) + } +} + func (i *IdentityStore) handleGroupReadCommon(ctx context.Context, group *identity.Group) (*logical.Response, error) { if group == nil { return nil, nil @@ -346,124 +378,160 @@ func (i *IdentityStore) pathGroupIDDelete() framework.OperationFunc { return logical.ErrorResponse("empty group ID"), nil } - if groupID == "" { - return nil, fmt.Errorf("missing group ID") - } + return i.handleGroupDeleteCommon(ctx, groupID, true) + } +} - // Acquire the lock to modify the group storage entry - i.groupLock.Lock() - defer i.groupLock.Unlock() +func (i *IdentityStore) pathGroupNameDelete() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + groupName := d.Get("name").(string) + if groupName == "" { + return logical.ErrorResponse("empty group name"), nil + } - // Create a MemDB transaction to delete group - txn := i.db.Txn(true) - defer txn.Abort() + return i.handleGroupDeleteCommon(ctx, groupName, false) + } +} - group, err := i.MemDBGroupByIDInTxn(txn, groupID, false) - if err != nil { - return nil, err - } +func (i *IdentityStore) handleGroupDeleteCommon(ctx context.Context, key string, byID bool) (*logical.Response, error) { + // Acquire the lock to modify the group storage entry + i.groupLock.Lock() + defer i.groupLock.Unlock() - // If there is no group for the ID, do nothing - if group == nil { - return nil, nil - } + // Create a MemDB transaction to delete group + txn := i.db.Txn(true) + defer txn.Abort() - ns, err := namespace.FromContext(ctx) + var group *identity.Group + var err error + switch byID { + case true: + group, err = i.MemDBGroupByIDInTxn(txn, key, false) if err != nil { return nil, err } - if group.NamespaceID != ns.ID { - return nil, logical.ErrUnsupportedPath - } - - // Delete group alias from memdb - if group.Type == groupTypeExternal && group.Alias != nil { - err = i.MemDBDeleteAliasByIDInTxn(txn, group.Alias.ID, true) - if err != nil { - return nil, err - } - } - - // Delete the group using the same transaction - err = i.MemDBDeleteGroupByIDInTxn(txn, group.ID) + default: + group, err = i.MemDBGroupByNameInTxn(ctx, txn, key, false) if err != nil { return nil, err } + } + if group == nil { + return nil, nil + } + + ns, err := namespace.FromContext(ctx) + if err != nil { + return nil, err + } + if group.NamespaceID != ns.ID { + return nil, logical.ErrUnsupportedPath + } - // Delete the group from storage - err = i.groupPacker.DeleteItem(group.ID) + // Delete group alias from memdb + if group.Type == groupTypeExternal && group.Alias != nil { + err = i.MemDBDeleteAliasByIDInTxn(txn, group.Alias.ID, true) if err != nil { return nil, err } + } - // Committing the transaction *after* successfully deleting group - txn.Commit() + // Delete the group using the same transaction + err = i.MemDBDeleteGroupByIDInTxn(txn, group.ID) + if err != nil { + return nil, err + } - return nil, nil + // Delete the group from storage + err = i.groupPacker.DeleteItem(group.ID) + if err != nil { + return nil, err } + + // Committing the transaction *after* successfully deleting group + txn.Commit() + + return nil, nil } // pathGroupIDList lists the IDs of all the groups in the identity store func (i *IdentityStore) pathGroupIDList() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - ns, err := namespace.FromContext(ctx) - if err != nil { - return nil, err - } + return i.handleGroupListCommon(ctx, true) + } +} - txn := i.db.Txn(false) +// pathGroupNameList lists the names of all the groups in the identity store +func (i *IdentityStore) pathGroupNameList() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + return i.handleGroupListCommon(ctx, false) + } +} - iter, err := txn.Get(groupsTable, "namespace_id", ns.ID) - if err != nil { - return nil, errwrap.Wrapf("failed to lookup groups using namespace ID: {{err}}", err) - } +func (i *IdentityStore) handleGroupListCommon(ctx context.Context, byID bool) (*logical.Response, error) { + ns, err := namespace.FromContext(ctx) + if err != nil { + return nil, err + } + + txn := i.db.Txn(false) + + iter, err := txn.Get(groupsTable, "namespace_id", ns.ID) + if err != nil { + return nil, errwrap.Wrapf("failed to lookup groups using namespace ID: {{err}}", err) + } + + var keys []string + groupInfo := map[string]interface{}{} + + type mountInfo struct { + MountType string + MountPath string + } + mountAccessorMap := map[string]mountInfo{} + + for entry := iter.Next(); entry != nil; entry = iter.Next() { + group := entry.(*identity.Group) - var groupIDs []string - groupInfo := map[string]interface{}{} + if byID { + keys = append(keys, group.ID) + } else { + keys = append(keys, group.Name) + } - type mountInfo struct { - MountType string - MountPath string + groupInfoEntry := map[string]interface{}{ + "name": group.Name, + "num_member_entities": len(group.MemberEntityIDs), + "num_parent_groups": len(group.ParentGroupIDs), } - mountAccessorMap := map[string]mountInfo{} - - for entry := iter.Next(); entry != nil; entry = iter.Next() { - group := entry.(*identity.Group) - groupIDs = append(groupIDs, group.ID) - groupInfoEntry := map[string]interface{}{ - "name": group.Name, - "num_member_entities": len(group.MemberEntityIDs), - "num_parent_groups": len(group.ParentGroupIDs), + if group.Alias != nil { + entry := map[string]interface{}{ + "id": group.Alias.ID, + "name": group.Alias.Name, + "mount_accessor": group.Alias.MountAccessor, } - if group.Alias != nil { - entry := map[string]interface{}{ - "id": group.Alias.ID, - "name": group.Alias.Name, - "mount_accessor": group.Alias.MountAccessor, - } - mi, ok := mountAccessorMap[group.Alias.MountAccessor] - if ok { + mi, ok := mountAccessorMap[group.Alias.MountAccessor] + if ok { + entry["mount_type"] = mi.MountType + entry["mount_path"] = mi.MountPath + } else { + mi = mountInfo{} + if mountValidationResp := i.core.router.validateMountByAccessor(group.Alias.MountAccessor); mountValidationResp != nil { + mi.MountType = mountValidationResp.MountType + mi.MountPath = mountValidationResp.MountPath entry["mount_type"] = mi.MountType entry["mount_path"] = mi.MountPath - } else { - mi = mountInfo{} - if mountValidationResp := i.core.router.validateMountByAccessor(group.Alias.MountAccessor); mountValidationResp != nil { - mi.MountType = mountValidationResp.MountType - mi.MountPath = mountValidationResp.MountPath - entry["mount_type"] = mi.MountType - entry["mount_path"] = mi.MountPath - } - mountAccessorMap[group.Alias.MountAccessor] = mi } - - groupInfoEntry["alias"] = entry + mountAccessorMap[group.Alias.MountAccessor] = mi } - groupInfo[group.ID] = groupInfoEntry - } - return logical.ListResponseWithInfo(groupIDs, groupInfo), nil + groupInfoEntry["alias"] = entry + } + groupInfo[group.ID] = groupInfoEntry } + + return logical.ListResponseWithInfo(keys, groupInfo), nil } var groupHelp = map[string][2]string{ diff --git a/vault/identity_store_groups_test.go b/vault/identity_store_groups_test.go index df6937008ad5..780424f7754f 100644 --- a/vault/identity_store_groups_test.go +++ b/vault/identity_store_groups_test.go @@ -11,6 +11,122 @@ import ( "github.com/hashicorp/vault/logical" ) +func TestIdentityStore_GroupByName(t *testing.T) { + ctx := namespace.RootContext(nil) + i, _, _ := testIdentityStoreWithGithubAuth(ctx, t) + + // Create an entity using the "name" endpoint + resp, err := i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + + // Test the read by name endpoint + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil || resp.Data["name"].(string) != "testgroupname" { + t.Fatalf("bad entity response: %#v", resp) + } + + // Update group metadata using the name endpoint + groupMetadata := map[string]string{ + "foo": "bar", + } + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.UpdateOperation, + Data: map[string]interface{}{ + "metadata": groupMetadata, + }, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + // Check the updated result + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil || !reflect.DeepEqual(resp.Data["metadata"].(map[string]string), groupMetadata) { + t.Fatalf("bad group response: %#v", resp) + } + + // Delete the group using the name endpoint + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.DeleteOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + // Check if deletion was successful + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.ReadOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp != nil { + t.Fatalf("expected a nil response") + } + + // Create 2 entities + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name/testgroupname2", + Operation: logical.UpdateOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + if resp == nil { + t.Fatalf("expected a non-nil response") + } + + // List the entities by name + resp, err = i.HandleRequest(ctx, &logical.Request{ + Path: "group/name", + Operation: logical.ListOperation, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr: %v", resp, err) + } + + expected := []string{"testgroupname2", "testgroupname"} + sort.Strings(expected) + actual := resp.Data["keys"].([]string) + sort.Strings(actual) + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: group list response; expected: %#v\nactual: %#v", expected, actual) + } +} + func TestIdentityStore_Groups_TypeMembershipAdditions(t *testing.T) { var err error var resp *logical.Response diff --git a/vault/identity_store_util.go b/vault/identity_store_util.go index 6102aaa57c7c..37fab4f17dfa 100644 --- a/vault/identity_store_util.go +++ b/vault/identity_store_util.go @@ -581,13 +581,21 @@ func (i *IdentityStore) MemDBEntityByName(ctx context.Context, entityName string return nil, fmt.Errorf("missing entity name") } + txn := i.db.Txn(false) + + return i.MemDBEntityByNameInTxn(txn, ctx, entityName, clone) +} + +func (i *IdentityStore) MemDBEntityByNameInTxn(txn *memdb.Txn, ctx context.Context, entityName string, clone bool) (*identity.Entity, error) { + if entityName == "" { + return nil, fmt.Errorf("missing entity name") + } + ns, err := namespace.FromContext(ctx) if err != nil { return nil, err } - txn := i.db.Txn(false) - entityRaw, err := txn.First(entitiesTable, "name", ns.ID, entityName) if err != nil { return nil, errwrap.Wrapf("failed to fetch entity from memdb using entity name: {{err}}", err) diff --git a/website/source/api/secret/identity/entity.html.md b/website/source/api/secret/identity/entity.html.md index 7dc4d5e4a7e4..007b2164b698 100644 --- a/website/source/api/secret/identity/entity.html.md +++ b/website/source/api/secret/identity/entity.html.md @@ -218,6 +218,158 @@ $ curl \ } ``` +## Create/Update Entity by Name + +This endpoint is used to create or update an entity by a given name. + +| Method | Path | Produces | +| :------- | :------------------------------- | :--------------------- | +| `POST` | `/identity/entity/name/:name` | `200 application/json` | + +### Parameters + +- `name` `(string: entity-)` – Name of the entity. + +- `metadata` `(key-value-map: {})` – Metadata to be associated with the entity. + +- `policies` `(list of strings: [])` – Policies to be tied to the entity. + +- `disabled` `(bool: false)` – Whether the entity is disabled. Disabled + entities' associated tokens cannot be used, but are not revoked. + +### Sample Payload + +```json +{ + "metadata": { + "organization": "hashi", + "team": "nomad" + }, + "policies": ["eng-developers", "infra-developers"] +} +``` + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/identity/entity/name/testentityname +``` + +### Sample Response + +```json +{ + "data": { + "aliases": null, + "id": "0826be06-577c-a076-3942-2f92da0310ce" + } +} +``` + +## Read Entity by Name + +This endpoint queries the entity by its name. + +| Method | Path | Produces | +| :------- | :------------------------------- | :--------------------- | +| `GET` | `/identity/entity/name/:name` | `200 application/json` | + +### Parameters + +- `name` `(string: )` – Name of the entity. + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/identity/entity/id/testentityname +``` + +### Sample Response + +```json +{ + "data": { + "aliases": [], + "creation_time": "2018-09-19T17:20:27.705389973Z", + "direct_group_ids": [], + "disabled": false, + "group_ids": [], + "id": "0826be06-577c-a076-3942-2f92da0310ce", + "inherited_group_ids": [], + "last_update_time": "2018-09-19T17:20:27.705389973Z", + "merged_entity_ids": null, + "metadata": { + "organization": "hashi", + "team": "nomad" + }, + "name": "testentityname", + "policies": [ + "eng-developers", + "infra-developers" + ] + } +} +``` + +## Delete Entity by Name + +This endpoint deletes an entity and all its associated aliases, given the +entity name. + +| Method | Path | Produces | +| :--------- | :------------------------------ | :----------------------| +| `DELETE` | `/identity/entity/name/:name` | `204 (empty body)` | + +## Parameters + +- `name` `(string: )` – Name of the entity. + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request DELETE \ + http://127.0.0.1:8200/v1/identity/entity/name/testentityname +``` + +## List Entities by Name + +This endpoint returns a list of available entities by their names. + +| Method | Path | Produces | +| :------- | :-------------------------------- | :--------------------- | +| `LIST` | `/identity/entity/name` | `200 application/json` | +| `GET` | `/identity/entity/name?list=true` | `200 application/json` | + + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request LIST \ + http://127.0.0.1:8200/v1/identity/entity/name +``` + +### Sample Response + +```json +{ + "data": { + "keys": [ + "testentityname", + ] + } +} +``` + ## Merge Entities This endpoint merges many entities into one entity. diff --git a/website/source/api/secret/identity/group.html.md b/website/source/api/secret/identity/group.html.md index c61db936495b..f478d5dde11d 100644 --- a/website/source/api/secret/identity/group.html.md +++ b/website/source/api/secret/identity/group.html.md @@ -228,3 +228,163 @@ $ curl \ } } ``` + +## Create/Update Group by Name + +This endpoint is used to create or update a group by its name. + +| Method | Path | Produces | +| :------- | :------------------------------ | :--------------------- | +| `POST` | `/identity/group/name/:name` | `200 application/json` | + + +### Parameters + +- `name` `(string: entity-)` – Name of the group. + +- `type` `(string: "internal")` - Type of the group, `internal` or `external`. + Defaults to `internal`. + +- `metadata` `(key-value-map: {})` – Metadata to be associated with the + group. + +- `policies` `(list of strings: [])` – Policies to be tied to the group. + +- `member_group_ids` `(list of strings: [])` - Group IDs to be assigned as + group members. + +- `member_entity_ids` `(list of strings: [])` - Entity IDs to be assigned as + group members. + +### Sample Payload + +```json +{ + "metadata": { + "hello": "everyone" + }, + "policies": ["grouppolicy2", "grouppolicy3"] +} +``` + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/identity/group/name/testgroupname +``` + +### Sample Response +```json +{ + "request_id": "b98b4a3d-a9f1-e151-11e1-ad91cfb08351", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": { + "id": "5a3a04a0-0c3a-a4c3-74e8-26b1adbeaece", + "name": "testgroupname" + }, + "warnings": null +} +``` + +## Read Group by Name + +This endpoint queries the group by its name. + +| Method | Path | Produces | +| :------- | :------------------------------ | :--------------------- | +| `GET` | `/identity/group/name/:name` | `200 application/json` | + +### Parameters + +- `name` `(string: )` – Name of the group. + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/identity/group/name/testgroupname +``` + +### Sample Response + +```json +{ + "data": { + "alias": {}, + "creation_time": "2018-09-19T22:02:04.395128091Z", + "id": "5a3a04a0-0c3a-a4c3-74e8-26b1adbeaece", + "last_update_time": "2018-09-19T22:02:04.395128091Z", + "member_entity_ids": [], + "member_group_ids": null, + "metadata": { + "foo": "bar" + }, + "modify_index": 1, + "name": "testgroupname", + "parent_group_ids": null, + "policies": [ + "grouppolicy1", + "grouppolicy2" + ], + "type": "internal" + } +} +``` + +## Delete Group by Name + +This endpoint deletes a group, given its name. + +| Method | Path | Produces | +| :--------- | :----------------------------- | :----------------------| +| `DELETE` | `/identity/group/name/:name` | `204 (empty body)` | + +## Parameters + +- `name` `(string: )` – Name of the group. + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request DELETE \ + http://127.0.0.1:8200/v1/identity/group/name/testgroupname +``` + +## List Groups by Name + +This endpoint returns a list of available groups by their names. + +| Method | Path | Produces | +| :------- | :------------------------------- | :--------------------- | +| `LIST` | `/identity/group/name` | `200 application/json` | +| `GET` | `/identity/group/name?list=true` | `200 application/json` | + +### Sample Request + +``` +$ curl \ + --header "X-Vault-Token: ..." \ + --request LIST \ + http://127.0.0.1:8200/v1/identity/group/name +``` + +### Sample Response + +```json +{ + "data": { + "keys": [ + "testgroupname" + ] + } +} +``` From fdc5648f21b0ff9f8c6f552a51b194e90468181a Mon Sep 17 00:00:00 2001 From: vishalnayak Date: Tue, 25 Sep 2018 15:27:54 -0400 Subject: [PATCH 2/2] address review feedback --- vault/identity_store_entities.go | 4 ++-- vault/identity_store_groups.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vault/identity_store_entities.go b/vault/identity_store_entities.go index 8dd175449941..c1f06fa8107f 100644 --- a/vault/identity_store_entities.go +++ b/vault/identity_store_entities.go @@ -422,7 +422,7 @@ func (i *IdentityStore) pathEntityIDDelete() framework.OperationFunc { return nil, err } if entity.NamespaceID != ns.ID { - return nil, logical.ErrUnsupportedPath + return nil, nil } // Delete all the aliases in the entity. This function will also remove @@ -481,7 +481,7 @@ func (i *IdentityStore) pathEntityNameDelete() framework.OperationFunc { return nil, err } if entity.NamespaceID != ns.ID { - return nil, logical.ErrUnsupportedPath + return nil, nil } // Delete all the aliases in the entity. This function will also remove diff --git a/vault/identity_store_groups.go b/vault/identity_store_groups.go index 70d273fdfd2a..d8c3280bcdb3 100644 --- a/vault/identity_store_groups.go +++ b/vault/identity_store_groups.go @@ -425,7 +425,7 @@ func (i *IdentityStore) handleGroupDeleteCommon(ctx context.Context, key string, return nil, err } if group.NamespaceID != ns.ID { - return nil, logical.ErrUnsupportedPath + return nil, nil } // Delete group alias from memdb