// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package vault import ( "context" "fmt" "strings" "github.com/golang/protobuf/ptypes" "github.com/hashicorp/go-secure-stdlib/strutil" "github.com/hashicorp/vault/helper/identity" "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) const ( groupTypeInternal = "internal" groupTypeExternal = "external" ) 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.", }, } } func groupPaths(i *IdentityStore) []*framework.Path { return []*framework.Path{ { Pattern: "group$", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "group", OperationVerb: "create", }, Fields: groupPathFields(), Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: i.pathGroupRegister(), ForwardPerformanceStandby: true, }, }, HelpSynopsis: strings.TrimSpace(groupHelp["register"][0]), HelpDescription: strings.TrimSpace(groupHelp["register"][1]), }, { Pattern: "group/id/" + framework.GenericNameRegex("id"), DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "group", OperationSuffix: "by-id", }, Fields: groupPathFields(), Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: i.pathGroupIDUpdate(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "update", }, }, logical.ReadOperation: &framework.PathOperation{ Callback: i.pathGroupIDRead(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "read", }, }, logical.DeleteOperation: &framework.PathOperation{ Callback: i.pathGroupIDDelete(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "delete", }, }, }, HelpSynopsis: strings.TrimSpace(groupHelp["group-by-id"][0]), HelpDescription: strings.TrimSpace(groupHelp["group-by-id"][1]), }, { Pattern: "group/id/?$", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "group", OperationSuffix: "by-id", }, Callbacks: map[logical.Operation]framework.OperationFunc{ logical.ListOperation: i.pathGroupIDList(), }, HelpSynopsis: strings.TrimSpace(groupHelp["group-id-list"][0]), HelpDescription: strings.TrimSpace(groupHelp["group-id-list"][1]), }, { Pattern: "group/name/(?P.+)", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "group", OperationSuffix: "by-name", }, Fields: groupPathFields(), Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: i.pathGroupNameUpdate(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "update", }, }, logical.ReadOperation: &framework.PathOperation{ Callback: i.pathGroupNameRead(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "read", }, }, logical.DeleteOperation: &framework.PathOperation{ Callback: i.pathGroupNameDelete(), DisplayAttrs: &framework.DisplayAttributes{ OperationVerb: "delete", }, }, }, HelpSynopsis: strings.TrimSpace(groupHelp["group-by-name"][0]), HelpDescription: strings.TrimSpace(groupHelp["group-by-name"][1]), }, { Pattern: "group/name/?$", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "group", OperationSuffix: "by-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]), }, } } func (i *IdentityStore) pathGroupRegister() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { _, ok := d.GetOk("id") if ok { return i.pathGroupIDUpdate()(ctx, req, d) } _, ok = d.GetOk("name") if ok { return i.pathGroupNameUpdate()(ctx, req, d) } i.groupLock.Lock() defer i.groupLock.Unlock() return i.handleGroupUpdateCommon(ctx, req, d, nil) } } func (i *IdentityStore) pathGroupIDUpdate() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { groupID := d.Get("id").(string) if groupID == "" { return logical.ErrorResponse("empty group ID"), nil } i.groupLock.Lock() defer i.groupLock.Unlock() group, err := i.MemDBGroupByID(groupID, true) if err != nil { return nil, err } if group == nil { return logical.ErrorResponse("invalid group ID"), nil } return i.handleGroupUpdateCommon(ctx, req, d, group) } } 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 { group = new(identity.Group) newGroup = true } // Update the policies if supplied policiesRaw, ok := d.GetOk("policies") if ok { group.Policies = strutil.RemoveDuplicatesStable(policiesRaw.([]string), true) } if strutil.StrListContains(group.Policies, "root") { return logical.ErrorResponse("policies cannot contain root"), nil } groupTypeRaw, ok := d.GetOk("type") if ok { groupType := groupTypeRaw.(string) if group.Type != "" && groupType != group.Type { return logical.ErrorResponse(fmt.Sprintf("group type cannot be changed")), nil } group.Type = groupType } // If group type is not set, default to internal type if group.Type == "" { group.Type = groupTypeInternal } if group.Type != groupTypeInternal && group.Type != groupTypeExternal { return logical.ErrorResponse(fmt.Sprintf("invalid group type %q", group.Type)), nil } // Get the name groupName := d.Get("name").(string) if groupName != "" { // Check if there is a group already existing for the given name groupByName, err := i.MemDBGroupByName(ctx, groupName, false) if err != nil { return nil, err } // If no existing group has this name, go ahead with the creation or rename. // If there is a group, it must match the group passed in; groupByName // should not be modified as it's in memdb. switch { case groupByName == nil: // Allowed case groupByName.ID != group.ID: return logical.ErrorResponse("group name is already in use"), nil } group.Name = groupName } metadata, ok, err := d.GetOkErr("metadata") if err != nil { return logical.ErrorResponse(fmt.Sprintf("failed to parse metadata: %v", err)), nil } if ok { group.Metadata = metadata.(map[string]string) } memberEntityIDsRaw, ok := d.GetOk("member_entity_ids") if ok { if group.Type == groupTypeExternal { return logical.ErrorResponse("member entities can't be set manually for external groups"), nil } group.MemberEntityIDs = memberEntityIDsRaw.([]string) } memberGroupIDsRaw, ok := d.GetOk("member_group_ids") var memberGroupIDs []string if ok { if group.Type == groupTypeExternal { return logical.ErrorResponse("member groups can't be set for external groups"), nil } memberGroupIDs = memberGroupIDsRaw.([]string) } err = i.sanitizeAndUpsertGroup(ctx, group, nil, memberGroupIDs) if err != nil { if errStr := err.Error(); strings.HasPrefix(errStr, errCycleDetectedPrefix) { return logical.ErrorResponse(errStr), nil } return nil, err } if !newGroup { return nil, nil } respData := map[string]interface{}{ "id": group.ID, "name": group.Name, } return &logical.Response{ Data: respData, }, nil } func (i *IdentityStore) pathGroupIDRead() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { groupID := d.Get("id").(string) if groupID == "" { return logical.ErrorResponse("empty group id"), nil } group, err := i.MemDBGroupByID(groupID, false) if err != nil { return nil, err } if group == nil { return nil, nil } return i.handleGroupReadCommon(ctx, group) } } 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 } ns, err := namespace.FromContext(ctx) if err != nil { return nil, err } if ns.ID != group.NamespaceID { return logical.ErrorResponse("request namespace is not the same as the group namespace"), logical.ErrPermissionDenied } respData := map[string]interface{}{} respData["id"] = group.ID respData["name"] = group.Name respData["policies"] = group.Policies respData["member_entity_ids"] = group.MemberEntityIDs respData["parent_group_ids"] = group.ParentGroupIDs respData["metadata"] = group.Metadata respData["creation_time"] = ptypes.TimestampString(group.CreationTime) respData["last_update_time"] = ptypes.TimestampString(group.LastUpdateTime) respData["modify_index"] = group.ModifyIndex respData["type"] = group.Type respData["namespace_id"] = group.NamespaceID aliasMap := map[string]interface{}{} if group.Alias != nil { aliasMap["id"] = group.Alias.ID aliasMap["canonical_id"] = group.Alias.CanonicalID aliasMap["mount_accessor"] = group.Alias.MountAccessor aliasMap["metadata"] = group.Alias.Metadata aliasMap["name"] = group.Alias.Name aliasMap["merged_from_canonical_ids"] = group.Alias.MergedFromCanonicalIDs aliasMap["creation_time"] = ptypes.TimestampString(group.Alias.CreationTime) aliasMap["last_update_time"] = ptypes.TimestampString(group.Alias.LastUpdateTime) if mountValidationResp := i.router.ValidateMountByAccessor(group.Alias.MountAccessor); mountValidationResp != nil { aliasMap["mount_path"] = mountValidationResp.MountPath aliasMap["mount_type"] = mountValidationResp.MountType } } respData["alias"] = aliasMap var memberGroupIDs []string memberGroups, err := i.MemDBGroupsByParentGroupID(group.ID, false) if err != nil { return nil, err } for _, memberGroup := range memberGroups { memberGroupIDs = append(memberGroupIDs, memberGroup.ID) } respData["member_group_ids"] = memberGroupIDs return &logical.Response{ Data: respData, }, nil } func (i *IdentityStore) pathGroupIDDelete() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { groupID := d.Get("id").(string) if groupID == "" { return logical.ErrorResponse("empty group ID"), nil } return i.handleGroupDeleteCommon(ctx, groupID, true) } } 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 } return i.handleGroupDeleteCommon(ctx, groupName, false) } } 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() // Create a MemDB transaction to delete group txn := i.db.Txn(true) defer txn.Abort() var group *identity.Group var err error switch byID { case true: group, err = i.MemDBGroupByIDInTxn(txn, key, false) if err != nil { return nil, err } 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 logical.ErrorResponse("request namespace is not the same as the group namespace"), logical.ErrPermissionDenied } // 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) if err != nil { return nil, err } // Delete the group from storage err = i.groupPacker.DeleteItem(ctx, 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) { return i.handleGroupListCommon(ctx, true) } } // 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) } } 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, fmt.Errorf("failed to lookup groups using namespace ID: %w", 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) if byID { keys = append(keys, group.ID) } else { keys = append(keys, group.Name) } 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, } mi, ok := mountAccessorMap[group.Alias.MountAccessor] if ok { entry["mount_type"] = mi.MountType entry["mount_path"] = mi.MountPath } else { mi = mountInfo{} if mountValidationResp := i.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 } groupInfo[group.ID] = groupInfoEntry } return logical.ListResponseWithInfo(keys, groupInfo), nil } var groupHelp = map[string][2]string{ "register": { "Create a new group.", "", }, "group-by-id": { "Update or delete an existing group using its ID.", "", }, "group-id-list": { "List all the group IDs.", "", }, }