diff --git a/internal/api/httpsrv/handler_group.go b/internal/api/httpsrv/handler_group.go index 54668060..9d4a05cc 100644 --- a/internal/api/httpsrv/handler_group.go +++ b/internal/api/httpsrv/handler_group.go @@ -137,7 +137,7 @@ func (h *apiHandler) ListGroups(ctx context.Context, req ListGroupsRequestObject return nil, permissionsError(err) } - groups, err := h.engine.ListGroups(ctx, ownerID, req.Params) + groups, err := h.engine.ListGroupsByOwner(ctx, ownerID, req.Params) if err != nil { return nil, err } diff --git a/internal/api/httpsrv/handler_group_members.go b/internal/api/httpsrv/handler_group_members.go new file mode 100644 index 00000000..c46c0859 --- /dev/null +++ b/internal/api/httpsrv/handler_group_members.go @@ -0,0 +1,234 @@ +package httpsrv + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "go.infratographer.com/identity-api/internal/types" + v1 "go.infratographer.com/identity-api/pkg/api/v1" + "go.infratographer.com/permissions-api/pkg/permissions" + "go.infratographer.com/x/gidx" +) + +const ( + actionGroupMembersList = "iam_group_members_list" + actionGroupMembersAdd = "iam_group_members_add" + actionGroupMembersPut = "iam_group_members_put" + actionGroupMembersRemove = "iam_group_members_remove" +) + +// AddGroupMembers creates a group +func (h *apiHandler) AddGroupMembers(ctx context.Context, req AddGroupMembersRequestObject) (AddGroupMembersResponseObject, error) { + reqbody := req.Body + gid := req.GroupID + + if _, err := gidx.Parse(string(gid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid owner id: %s", err.Error()), + ) + + return nil, err + } + + for _, mid := range reqbody.MemberIDs { + if _, err := gidx.Parse(string(mid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid member id %s: %s", mid, err.Error()), + ) + + return nil, err + } + } + + if err := permissions.CheckAccess(ctx, gid, actionGroupMembersAdd); err != nil { + return nil, permissionsError(err) + } + + if err := h.engine.AddMembers(ctx, gid, reqbody.MemberIDs...); err != nil { + if errors.Is(err, types.ErrNotFound) { + err = echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return nil, err + } + + return AddGroupMembers200JSONResponse{Success: true}, nil +} + +// ListGroupMembers lists the members of a group +func (h *apiHandler) ListGroupMembers(ctx context.Context, req ListGroupMembersRequestObject) (ListGroupMembersResponseObject, error) { + gid := req.GroupID + + if _, err := gidx.Parse(string(gid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid group id: %s", err.Error()), + ) + + return nil, err + } + + if err := permissions.CheckAccess(ctx, gid, actionGroupMembersList); err != nil { + return nil, permissionsError(err) + } + + members, err := h.engine.ListMembers(ctx, gid, req.Params) + if err != nil { + if errors.Is(err, types.ErrNotFound) { + err = echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return nil, err + } + + collection := v1.GroupMemberCollection{ + MemberIDs: members, + GroupID: gid, + Pagination: v1.Pagination{}, + } + + if err := req.Params.SetPagination(&collection); err != nil { + return nil, err + } + + return ListGroupMembers200JSONResponse{GroupMemberCollectionJSONResponse(collection)}, nil +} + +// RemoveGroupMember removes a member from a group +func (h *apiHandler) RemoveGroupMember(ctx context.Context, req RemoveGroupMemberRequestObject) (RemoveGroupMemberResponseObject, error) { + gid := req.GroupID + sid := req.SubjectID + + if _, err := gidx.Parse(string(gid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid group id: %s", err.Error()), + ) + + return nil, err + } + + if _, err := gidx.Parse(string(sid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid member id: %s", err.Error()), + ) + } + + if _, err := gidx.Parse(string(sid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid member id: %s", err.Error()), + ) + + return nil, err + } + + if err := permissions.CheckAccess(ctx, gid, actionGroupMembersRemove); err != nil { + return nil, permissionsError(err) + } + + if err := h.engine.RemoveMember(ctx, gid, sid); err != nil { + if errors.Is(err, types.ErrNotFound) { + err = echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return nil, err + } + + return RemoveGroupMember200JSONResponse{true}, nil +} + +// ReplaceGroupMembers replaces the members of a group +func (h *apiHandler) ReplaceGroupMembers(ctx context.Context, req ReplaceGroupMembersRequestObject) (ReplaceGroupMembersResponseObject, error) { + gid := req.GroupID + reqbody := req.Body + + if _, err := gidx.Parse(string(gid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid group id: %s", err.Error()), + ) + + return nil, err + } + + for _, mid := range reqbody.MemberIDs { + if _, err := gidx.Parse(string(mid)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid member id %s: %s", mid, err.Error()), + ) + + return nil, err + } + } + + if err := permissions.CheckAccess(ctx, gid, actionGroupMembersPut); err != nil { + return nil, permissionsError(err) + } + + if err := h.engine.ReplaceMembers(ctx, gid, reqbody.MemberIDs...); err != nil { + if errors.Is(err, types.ErrNotFound) { + err = echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return nil, err + } + + return ReplaceGroupMembers200JSONResponse{true}, nil +} + +func (h *apiHandler) ListUserGroups(ctx context.Context, req ListUserGroupsRequestObject) (ListUserGroupsResponseObject, error) { + subject := req.UserID + + if _, err := gidx.Parse(string(subject)); err != nil { + err = echo.NewHTTPError( + http.StatusBadRequest, + fmt.Sprintf("invalid subject id: %s", err.Error()), + ) + + return nil, err + } + + // Find the owner the user's issuer is on to check permissions. + ownerID, err := h.engine.LookupUserOwnerID(ctx, subject) + switch err { + case nil: + case types.ErrUserInfoNotFound: + return nil, echo.NewHTTPError(http.StatusNotFound, err.Error()) + default: + return nil, err + } + + if err := permissions.CheckAccess(ctx, ownerID, actionUserGet); err != nil { + return nil, permissionsError(err) + } + + groups, err := h.engine.ListGroupsBySubject(ctx, subject, req.Params) + if err != nil { + if errors.Is(err, types.ErrNotFound) { + err = echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return nil, err + } + + resp := groups.ToPrefixedIDs() + + collection := v1.GroupIDCollection{ + GroupIDs: resp, + Pagination: v1.Pagination{}, + } + + if err := req.Params.SetPagination(&collection); err != nil { + return nil, err + } + + return ListUserGroups200JSONResponse{GroupIDCollectionJSONResponse(collection)}, nil +} diff --git a/internal/api/httpsrv/handler_group_members_test.go b/internal/api/httpsrv/handler_group_members_test.go new file mode 100644 index 00000000..a42ef83b --- /dev/null +++ b/internal/api/httpsrv/handler_group_members_test.go @@ -0,0 +1,511 @@ +package httpsrv + +import ( + "context" + "net/http" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + pagination "go.infratographer.com/identity-api/internal/crdbx" + "go.infratographer.com/identity-api/internal/storage" + "go.infratographer.com/identity-api/internal/testingx" + "go.infratographer.com/identity-api/internal/types" + v1 "go.infratographer.com/identity-api/pkg/api/v1" + "go.infratographer.com/x/crdbx" + "go.infratographer.com/x/gidx" +) + +func TestGroupMembersAPIHandler(t *testing.T) { + t.Parallel() + + testServer, err := storage.InMemoryCRDB() + if !assert.NoError(t, err) { + assert.FailNow(t, "initialization failed") + } + + err = testServer.Start() + if !assert.NoError(t, err) { + assert.FailNow(t, "initialization failed") + } + + t.Cleanup(func() { + testServer.Stop() + }) + + ownerID := gidx.MustNewID("testten") + + config := crdbx.Config{ + URI: testServer.PGURL().String(), + } + + store, err := storage.NewEngine(config, storage.WithMigrations()) + if !assert.NoError(t, err) { + assert.FailNow(t, "initialization failed") + } + + setupFn := func(ctx context.Context) context.Context { + ctx, err := store.BeginContext(ctx) + if !assert.NoError(t, err) { + assert.FailNow(t, "setup failed") + } + + return ctx + } + + cleanupFn := func(ctx context.Context) { + err := store.RollbackContext(ctx) + assert.NoError(t, err) + } + + t.Run("ListGroupMembers", func(t *testing.T) { + t.Parallel() + + handler := apiHandler{engine: store} + + testGroup := &types.Group{ + ID: gidx.MustNewID(types.IdentityGroupIDPrefix), + OwnerID: ownerID, + Name: "test-list-group-members", + } + + someMembers := []gidx.PrefixedID{ + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + } + + withStoredGroupAndMembers(t, store, testGroup, someMembers...) + + tc := []testingx.TestCase[ListGroupMembersRequestObject, ListGroupMembersResponseObject]{ + { + Name: "Invalid group id", + Input: ListGroupMembersRequestObject{GroupID: "definitely not a valid group id"}, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[ListGroupMembersResponseObject]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Group not found", + Input: ListGroupMembersRequestObject{GroupID: gidx.MustNewID(types.IdentityGroupIDPrefix)}, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[ListGroupMembersResponseObject]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusNotFound, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Success default pagination", + Input: ListGroupMembersRequestObject{GroupID: testGroup.ID}, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[ListGroupMembersResponseObject]) { + assert.Nil(t, res.Err) + assert.IsType(t, ListGroupMembers200JSONResponse{}, res.Success) + + members := res.Success.(ListGroupMembers200JSONResponse) + assert.Len(t, members.MemberIDs, len(someMembers)) + assert.NotNil(t, members.Pagination.Limit) + }, + }, + { + Name: "Success custom pagination", + Input: ListGroupMembersRequestObject{ + GroupID: testGroup.ID, + Params: v1.ListGroupMembersParams{ + Limit: ptr(1), + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[ListGroupMembersResponseObject]) { + assert.Nil(t, res.Err) + assert.IsType(t, ListGroupMembers200JSONResponse{}, res.Success) + + members := res.Success.(ListGroupMembers200JSONResponse) + assert.Len(t, members.MemberIDs, 1) + assert.Equal(t, members.Pagination.Limit, 1) + assert.NotNil(t, members.Pagination.Next) + }, + }, + } + + runFn := func(ctx context.Context, input ListGroupMembersRequestObject) testingx.TestResult[ListGroupMembersResponseObject] { + ctx = pagination.AsOfSystemTime(ctx, "") + resp, err := handler.ListGroupMembers(ctx, input) + + return testingx.TestResult[ListGroupMembersResponseObject]{Success: resp, Err: err} + } + + testingx.RunTests(ctxPermsAllow(context.Background()), t, tc, runFn) + }) + + t.Run("AddGroupMembers", func(t *testing.T) { + t.Parallel() + + handler := apiHandler{engine: store} + + testGroupWithNoMembers := &types.Group{ + ID: gidx.MustNewID(types.IdentityGroupIDPrefix), + OwnerID: ownerID, + Name: "test-add-group-members", + } + + testGroupWithSomeMembers := &types.Group{ + ID: gidx.MustNewID(types.IdentityGroupIDPrefix), + OwnerID: ownerID, + Name: "test-add-group-members-with-some-members", + } + + someMembers := []gidx.PrefixedID{ + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + } + + withStoredGroupAndMembers(t, store, testGroupWithNoMembers) + withStoredGroupAndMembers(t, store, testGroupWithSomeMembers, someMembers...) + + tc := []testingx.TestCase[AddGroupMembersRequestObject, []gidx.PrefixedID]{ + { + Name: "Invalid group id", + Input: AddGroupMembersRequestObject{GroupID: "definitely not a valid group id"}, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Group not found", + Input: AddGroupMembersRequestObject{ + GroupID: gidx.MustNewID(types.IdentityGroupIDPrefix), + Body: &v1.AddGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{gidx.MustNewID(types.IdentityUserIDPrefix)}, + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusNotFound, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Invalid member id", + Input: AddGroupMembersRequestObject{ + GroupID: testGroupWithNoMembers.ID, + Body: &v1.AddGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{"definitely not a valid member id"}, + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Success", + Input: AddGroupMembersRequestObject{ + GroupID: testGroupWithNoMembers.ID, + Body: &v1.AddGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{gidx.MustNewID(types.IdentityUserIDPrefix)}, + }, + }, + SetupFn: setupFn, + CleanupFn: func(_ context.Context) {}, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Err) + assert.Len(t, res.Success, 1) + }, + }, + { + Name: "Success with adding existing members", + Input: AddGroupMembersRequestObject{ + GroupID: testGroupWithSomeMembers.ID, + Body: &v1.AddGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{someMembers[0]}, + }, + }, + SetupFn: setupFn, + CleanupFn: func(_ context.Context) {}, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Err) + assert.Len(t, res.Success, len(someMembers)) + }, + }, + } + + runFn := func(ctx context.Context, input AddGroupMembersRequestObject) testingx.TestResult[[]gidx.PrefixedID] { + _, err := handler.AddGroupMembers(ctx, input) + if err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + if err := store.CommitContext(ctx); err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + ctx = context.Background() + ctx = pagination.AsOfSystemTime(ctx, "") + p := v1.ListGroupMembersParams{} + mm, err := store.ListMembers(ctx, input.GroupID, p) + + return testingx.TestResult[[]gidx.PrefixedID]{Success: mm, Err: err} + } + + testingx.RunTests(ctxPermsAllow(context.Background()), t, tc, runFn) + }) + + t.Run("RemoveGroupMember", func(t *testing.T) { + t.Parallel() + + handler := apiHandler{engine: store} + + testGroup := &types.Group{ + ID: gidx.MustNewID(types.IdentityGroupIDPrefix), + OwnerID: ownerID, + Name: "test-remove-group-member", + } + + someMembers := []gidx.PrefixedID{ + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + } + + withStoredGroupAndMembers(t, store, testGroup, someMembers...) + + tc := []testingx.TestCase[RemoveGroupMemberRequestObject, []gidx.PrefixedID]{ + { + Name: "Invalid group id", + Input: RemoveGroupMemberRequestObject{ + GroupID: "definitely not a valid group id", + SubjectID: gidx.MustNewID(types.IdentityUserIDPrefix), + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Invalid member id", + Input: RemoveGroupMemberRequestObject{ + GroupID: testGroup.ID, + SubjectID: "definitely not a valid member id", + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Group not found", + Input: RemoveGroupMemberRequestObject{ + GroupID: gidx.MustNewID(types.IdentityGroupIDPrefix), + SubjectID: gidx.MustNewID(types.IdentityUserIDPrefix), + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusNotFound, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Member not found", + Input: RemoveGroupMemberRequestObject{ + GroupID: testGroup.ID, + SubjectID: gidx.MustNewID(types.IdentityUserIDPrefix), + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusNotFound, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Success", + Input: RemoveGroupMemberRequestObject{ + GroupID: testGroup.ID, + SubjectID: someMembers[0], + }, + SetupFn: setupFn, + CleanupFn: func(_ context.Context) {}, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Err) + assert.Len(t, res.Success, len(someMembers)-1) + }, + }, + } + + runFn := func(ctx context.Context, input RemoveGroupMemberRequestObject) testingx.TestResult[[]gidx.PrefixedID] { + _, err := handler.RemoveGroupMember(ctx, input) + if err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + if err := store.CommitContext(ctx); err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + ctx = context.Background() + ctx = pagination.AsOfSystemTime(ctx, "") + p := v1.ListGroupMembersParams{} + mm, err := store.ListMembers(ctx, input.GroupID, p) + + return testingx.TestResult[[]gidx.PrefixedID]{Success: mm, Err: err} + } + + testingx.RunTests(ctxPermsAllow(context.Background()), t, tc, runFn) + }) + + t.Run("PutGroupMembers", func(t *testing.T) { + t.Parallel() + + handler := apiHandler{engine: store} + + testGroup := &types.Group{ + ID: gidx.MustNewID(types.IdentityGroupIDPrefix), + OwnerID: ownerID, + Name: "test-put-group-members", + } + + someMembers := []gidx.PrefixedID{ + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + gidx.MustNewID(types.IdentityUserIDPrefix), + } + + withStoredGroupAndMembers(t, store, testGroup, someMembers...) + + tc := []testingx.TestCase[ReplaceGroupMembersRequestObject, []gidx.PrefixedID]{ + { + Name: "Invalid group id", + Input: ReplaceGroupMembersRequestObject{ + GroupID: "definitely not a valid group id", + Body: &v1.ReplaceGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{gidx.MustNewID(types.IdentityUserIDPrefix)}, + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Invalid member id", + Input: ReplaceGroupMembersRequestObject{ + GroupID: testGroup.ID, + Body: &v1.ReplaceGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{"definitely not a valid member id"}, + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusBadRequest, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Group not found", + Input: ReplaceGroupMembersRequestObject{ + GroupID: gidx.MustNewID(types.IdentityGroupIDPrefix), + Body: &v1.ReplaceGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{gidx.MustNewID(types.IdentityUserIDPrefix)}, + }, + }, + SetupFn: setupFn, + CleanupFn: cleanupFn, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Success) + assert.IsType(t, &echo.HTTPError{}, res.Err) + assert.Equal(t, http.StatusNotFound, res.Err.(*echo.HTTPError).Code) + }, + }, + { + Name: "Success", + Input: ReplaceGroupMembersRequestObject{ + GroupID: testGroup.ID, + Body: &v1.ReplaceGroupMembersJSONRequestBody{ + MemberIDs: []gidx.PrefixedID{gidx.MustNewID(types.IdentityUserIDPrefix)}, + }, + }, + SetupFn: setupFn, + CleanupFn: func(_ context.Context) {}, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]gidx.PrefixedID]) { + assert.Nil(t, res.Err) + assert.Len(t, res.Success, 1) + }, + }, + } + + runFn := func(ctx context.Context, input ReplaceGroupMembersRequestObject) testingx.TestResult[[]gidx.PrefixedID] { + _, err := handler.ReplaceGroupMembers(ctx, input) + if err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + if err := store.CommitContext(ctx); err != nil { + return testingx.TestResult[[]gidx.PrefixedID]{Err: err} + } + + ctx = context.Background() + ctx = pagination.AsOfSystemTime(ctx, "") + p := v1.ListGroupMembersParams{} + mm, err := store.ListMembers(ctx, input.GroupID, p) + + return testingx.TestResult[[]gidx.PrefixedID]{Success: mm, Err: err} + } + + testingx.RunTests(ctxPermsAllow(context.Background()), t, tc, runFn) + }) +} + +func withStoredGroupAndMembers(t *testing.T, s storage.Engine, group *types.Group, m ...gidx.PrefixedID) { + seedCtx, err := s.BeginContext(context.Background()) + if !assert.NoError(t, err) { + assert.FailNow(t, "failed to begin context") + } + + g, err := s.CreateGroup(seedCtx, *group) + if !assert.NoError(t, err) { + assert.FailNow(t, "failed to create group") + } + + *group = *g + + if err := s.AddMembers(seedCtx, group.ID, m...); !assert.NoError(t, err) { + assert.FailNow(t, "failed to add members") + } + + if err := s.CommitContext(seedCtx); !assert.NoError(t, err) { + assert.FailNow(t, "error committing seed groups") + } +} diff --git a/internal/api/httpsrv/server.gen.go b/internal/api/httpsrv/server.gen.go index 712017b9..3fc8bc42 100644 --- a/internal/api/httpsrv/server.gen.go +++ b/internal/api/httpsrv/server.gen.go @@ -33,6 +33,18 @@ type ServerInterface interface { // Updates a Group // (PATCH /api/v1/groups/{groupID}) UpdateGroup(ctx echo.Context, groupID GroupID) error + // Gets members of a Group + // (GET /api/v1/groups/{groupID}/members) + ListGroupMembers(ctx echo.Context, groupID GroupID, params ListGroupMembersParams) error + // Adds a member to a Group + // (POST /api/v1/groups/{groupID}/members) + AddGroupMembers(ctx echo.Context, groupID GroupID) error + // Replaces members of a Group + // (PUT /api/v1/groups/{groupID}/members) + ReplaceGroupMembers(ctx echo.Context, groupID GroupID) error + // Removes a member from a Group + // (DELETE /api/v1/groups/{groupID}/members/{subjectID}) + RemoveGroupMember(ctx echo.Context, groupID GroupID, subjectID SubjectID) error // Deletes an issuer with the given ID. // (DELETE /api/v1/issuers/{id}) DeleteIssuer(ctx echo.Context, id gidx.PrefixedID) error @@ -66,6 +78,9 @@ type ServerInterface interface { // Gets information about a User. // (GET /api/v1/users/{userID}) GetUserByID(ctx echo.Context, userID gidx.PrefixedID) error + // Lists groups by user id + // (GET /api/v1/users/{userID}/groups) + ListUserGroups(ctx echo.Context, userID gidx.PrefixedID, params ListUserGroupsParams) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -153,6 +168,94 @@ func (w *ServerInterfaceWrapper) UpdateGroup(ctx echo.Context) error { return err } +// ListGroupMembers converts echo context to params. +func (w *ServerInterfaceWrapper) ListGroupMembers(ctx echo.Context) error { + var err error + // ------------- Path parameter "groupID" ------------- + var groupID GroupID + + err = runtime.BindStyledParameterWithOptions("simple", "groupID", ctx.Param("groupID"), &groupID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter groupID: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListGroupMembersParams + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", ctx.QueryParams(), ¶ms.Cursor) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter cursor: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ListGroupMembers(ctx, groupID, params) + return err +} + +// AddGroupMembers converts echo context to params. +func (w *ServerInterfaceWrapper) AddGroupMembers(ctx echo.Context) error { + var err error + // ------------- Path parameter "groupID" ------------- + var groupID GroupID + + err = runtime.BindStyledParameterWithOptions("simple", "groupID", ctx.Param("groupID"), &groupID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter groupID: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.AddGroupMembers(ctx, groupID) + return err +} + +// ReplaceGroupMembers converts echo context to params. +func (w *ServerInterfaceWrapper) ReplaceGroupMembers(ctx echo.Context) error { + var err error + // ------------- Path parameter "groupID" ------------- + var groupID GroupID + + err = runtime.BindStyledParameterWithOptions("simple", "groupID", ctx.Param("groupID"), &groupID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter groupID: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ReplaceGroupMembers(ctx, groupID) + return err +} + +// RemoveGroupMember converts echo context to params. +func (w *ServerInterfaceWrapper) RemoveGroupMember(ctx echo.Context) error { + var err error + // ------------- Path parameter "groupID" ------------- + var groupID GroupID + + err = runtime.BindStyledParameterWithOptions("simple", "groupID", ctx.Param("groupID"), &groupID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter groupID: %s", err)) + } + + // ------------- Path parameter "subjectID" ------------- + var subjectID SubjectID + + err = runtime.BindStyledParameterWithOptions("simple", "subjectID", ctx.Param("subjectID"), &subjectID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter subjectID: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RemoveGroupMember(ctx, groupID, subjectID) + return err +} + // DeleteIssuer converts echo context to params. func (w *ServerInterfaceWrapper) DeleteIssuer(ctx echo.Context) error { var err error @@ -393,6 +496,38 @@ func (w *ServerInterfaceWrapper) GetUserByID(ctx echo.Context) error { return err } +// ListUserGroups converts echo context to params. +func (w *ServerInterfaceWrapper) ListUserGroups(ctx echo.Context) error { + var err error + // ------------- Path parameter "userID" ------------- + var userID gidx.PrefixedID + + err = runtime.BindStyledParameterWithOptions("simple", "userID", ctx.Param("userID"), &userID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter userID: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListUserGroupsParams + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", ctx.QueryParams(), ¶ms.Cursor) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter cursor: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ListUserGroups(ctx, userID, params) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -426,6 +561,10 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.DELETE(baseURL+"/api/v1/groups/:groupID", wrapper.DeleteGroup) router.GET(baseURL+"/api/v1/groups/:groupID", wrapper.GetGroupByID) router.PATCH(baseURL+"/api/v1/groups/:groupID", wrapper.UpdateGroup) + router.GET(baseURL+"/api/v1/groups/:groupID/members", wrapper.ListGroupMembers) + router.POST(baseURL+"/api/v1/groups/:groupID/members", wrapper.AddGroupMembers) + router.PUT(baseURL+"/api/v1/groups/:groupID/members", wrapper.ReplaceGroupMembers) + router.DELETE(baseURL+"/api/v1/groups/:groupID/members/:subjectID", wrapper.RemoveGroupMember) router.DELETE(baseURL+"/api/v1/issuers/:id", wrapper.DeleteIssuer) router.GET(baseURL+"/api/v1/issuers/:id", wrapper.GetIssuerByID) router.PATCH(baseURL+"/api/v1/issuers/:id", wrapper.UpdateIssuer) @@ -437,6 +576,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/api/v1/owners/:ownerID/issuers", wrapper.ListOwnerIssuers) router.POST(baseURL+"/api/v1/owners/:ownerID/issuers", wrapper.CreateIssuer) router.GET(baseURL+"/api/v1/users/:userID", wrapper.GetUserByID) + router.GET(baseURL+"/api/v1/users/:userID/groups", wrapper.ListUserGroups) } @@ -447,6 +587,21 @@ type GroupCollectionJSONResponse struct { Pagination Pagination `json:"pagination"` } +type GroupIDCollectionJSONResponse struct { + GroupIDs []gidx.PrefixedID `json:"group_ids"` + + // Pagination collection response pagination + Pagination Pagination `json:"pagination"` +} + +type GroupMemberCollectionJSONResponse struct { + GroupID gidx.PrefixedID `json:"group_id"` + MemberIDs []gidx.PrefixedID `json:"member_ids"` + + // Pagination collection response pagination + Pagination Pagination `json:"pagination"` +} + type IssuerCollectionJSONResponse struct { Issuers []Issuer `json:"issuers"` @@ -553,6 +708,80 @@ func (response UpdateGroup200JSONResponse) VisitUpdateGroupResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type ListGroupMembersRequestObject struct { + GroupID GroupID `json:"groupID"` + Params ListGroupMembersParams +} + +type ListGroupMembersResponseObject interface { + VisitListGroupMembersResponse(w http.ResponseWriter) error +} + +type ListGroupMembers200JSONResponse struct { + GroupMemberCollectionJSONResponse +} + +func (response ListGroupMembers200JSONResponse) VisitListGroupMembersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddGroupMembersRequestObject struct { + GroupID GroupID `json:"groupID"` + Body *AddGroupMembersJSONRequestBody +} + +type AddGroupMembersResponseObject interface { + VisitAddGroupMembersResponse(w http.ResponseWriter) error +} + +type AddGroupMembers200JSONResponse AddGroupMembersResponse + +func (response AddGroupMembers200JSONResponse) VisitAddGroupMembersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ReplaceGroupMembersRequestObject struct { + GroupID GroupID `json:"groupID"` + Body *ReplaceGroupMembersJSONRequestBody +} + +type ReplaceGroupMembersResponseObject interface { + VisitReplaceGroupMembersResponse(w http.ResponseWriter) error +} + +type ReplaceGroupMembers200JSONResponse AddGroupMembersResponse + +func (response ReplaceGroupMembers200JSONResponse) VisitReplaceGroupMembersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type RemoveGroupMemberRequestObject struct { + GroupID GroupID `json:"groupID"` + SubjectID SubjectID `json:"subjectID"` +} + +type RemoveGroupMemberResponseObject interface { + VisitRemoveGroupMemberResponse(w http.ResponseWriter) error +} + +type RemoveGroupMember200JSONResponse DeleteResponse + +func (response RemoveGroupMember200JSONResponse) VisitRemoveGroupMemberResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type DeleteIssuerRequestObject struct { Id gidx.PrefixedID `json:"id"` } @@ -750,6 +979,24 @@ func (response GetUserByID200JSONResponse) VisitGetUserByIDResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type ListUserGroupsRequestObject struct { + UserID gidx.PrefixedID `json:"userID"` + Params ListUserGroupsParams +} + +type ListUserGroupsResponseObject interface { + VisitListUserGroupsResponse(w http.ResponseWriter) error +} + +type ListUserGroups200JSONResponse struct{ GroupIDCollectionJSONResponse } + +func (response ListUserGroups200JSONResponse) VisitListUserGroupsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Deletes an OAuth Client @@ -767,6 +1014,18 @@ type StrictServerInterface interface { // Updates a Group // (PATCH /api/v1/groups/{groupID}) UpdateGroup(ctx context.Context, request UpdateGroupRequestObject) (UpdateGroupResponseObject, error) + // Gets members of a Group + // (GET /api/v1/groups/{groupID}/members) + ListGroupMembers(ctx context.Context, request ListGroupMembersRequestObject) (ListGroupMembersResponseObject, error) + // Adds a member to a Group + // (POST /api/v1/groups/{groupID}/members) + AddGroupMembers(ctx context.Context, request AddGroupMembersRequestObject) (AddGroupMembersResponseObject, error) + // Replaces members of a Group + // (PUT /api/v1/groups/{groupID}/members) + ReplaceGroupMembers(ctx context.Context, request ReplaceGroupMembersRequestObject) (ReplaceGroupMembersResponseObject, error) + // Removes a member from a Group + // (DELETE /api/v1/groups/{groupID}/members/{subjectID}) + RemoveGroupMember(ctx context.Context, request RemoveGroupMemberRequestObject) (RemoveGroupMemberResponseObject, error) // Deletes an issuer with the given ID. // (DELETE /api/v1/issuers/{id}) DeleteIssuer(ctx context.Context, request DeleteIssuerRequestObject) (DeleteIssuerResponseObject, error) @@ -800,6 +1059,9 @@ type StrictServerInterface interface { // Gets information about a User. // (GET /api/v1/users/{userID}) GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) + // Lists groups by user id + // (GET /api/v1/users/{userID}/groups) + ListUserGroups(ctx context.Context, request ListUserGroupsRequestObject) (ListUserGroupsResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -945,6 +1207,120 @@ func (sh *strictHandler) UpdateGroup(ctx echo.Context, groupID GroupID) error { return nil } +// ListGroupMembers operation middleware +func (sh *strictHandler) ListGroupMembers(ctx echo.Context, groupID GroupID, params ListGroupMembersParams) error { + var request ListGroupMembersRequestObject + + request.GroupID = groupID + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ListGroupMembers(ctx.Request().Context(), request.(ListGroupMembersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListGroupMembers") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ListGroupMembersResponseObject); ok { + return validResponse.VisitListGroupMembersResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// AddGroupMembers operation middleware +func (sh *strictHandler) AddGroupMembers(ctx echo.Context, groupID GroupID) error { + var request AddGroupMembersRequestObject + + request.GroupID = groupID + + var body AddGroupMembersJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.AddGroupMembers(ctx.Request().Context(), request.(AddGroupMembersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddGroupMembers") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(AddGroupMembersResponseObject); ok { + return validResponse.VisitAddGroupMembersResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ReplaceGroupMembers operation middleware +func (sh *strictHandler) ReplaceGroupMembers(ctx echo.Context, groupID GroupID) error { + var request ReplaceGroupMembersRequestObject + + request.GroupID = groupID + + var body ReplaceGroupMembersJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ReplaceGroupMembers(ctx.Request().Context(), request.(ReplaceGroupMembersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ReplaceGroupMembers") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ReplaceGroupMembersResponseObject); ok { + return validResponse.VisitReplaceGroupMembersResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// RemoveGroupMember operation middleware +func (sh *strictHandler) RemoveGroupMember(ctx echo.Context, groupID GroupID, subjectID SubjectID) error { + var request RemoveGroupMemberRequestObject + + request.GroupID = groupID + request.SubjectID = subjectID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RemoveGroupMember(ctx.Request().Context(), request.(RemoveGroupMemberRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RemoveGroupMember") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RemoveGroupMemberResponseObject); ok { + return validResponse.VisitRemoveGroupMemberResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // DeleteIssuer operation middleware func (sh *strictHandler) DeleteIssuer(ctx echo.Context, id gidx.PrefixedID) error { var request DeleteIssuerRequestObject @@ -1247,3 +1623,29 @@ func (sh *strictHandler) GetUserByID(ctx echo.Context, userID gidx.PrefixedID) e } return nil } + +// ListUserGroups operation middleware +func (sh *strictHandler) ListUserGroups(ctx echo.Context, userID gidx.PrefixedID, params ListUserGroupsParams) error { + var request ListUserGroupsRequestObject + + request.UserID = userID + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ListUserGroups(ctx.Request().Context(), request.(ListUserGroupsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListUserGroups") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ListUserGroupsResponseObject); ok { + return validResponse.VisitListUserGroupsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/internal/storage/groups.go b/internal/storage/groups.go index 01005142..ad08bd14 100644 --- a/internal/storage/groups.go +++ b/internal/storage/groups.go @@ -24,6 +24,14 @@ var groupCols = struct { Description: "description", } +var groupMemberCols = struct { + GroupID string + SubjectID string +}{ + GroupID: "group_id", + SubjectID: "subject_id", +} + var groupColsStr = strings.Join([]string{ groupCols.ID, groupCols.OwnerID, groupCols.Name, groupCols.Description, @@ -104,19 +112,20 @@ func (gs *groupService) fetchGroupByID(ctx context.Context, id gidx.PrefixedID) groupColsStr, groupCols.ID, ) - var row *sql.Row + var ex func(ctx context.Context, query string, args ...any) *sql.Row tx, err := getContextTx(ctx) - switch err { case nil: - row = tx.QueryRowContext(ctx, q, id) + ex = tx.QueryRowContext case ErrorMissingContextTx: - row = gs.db.QueryRowContext(ctx, q, id) + ex = gs.db.QueryRowContext default: return nil, err } + row := ex(ctx, q, id) + return gs.scanGroup(row) } @@ -138,7 +147,7 @@ func (gs *groupService) scanGroup(row *sql.Row) (*types.Group, error) { return &g, nil } -func (gs *groupService) ListGroups(ctx context.Context, ownerID gidx.PrefixedID, pagination crdbx.Paginator) (types.Groups, error) { +func (gs *groupService) ListGroupsByOwner(ctx context.Context, ownerID gidx.PrefixedID, pagination crdbx.Paginator) (types.Groups, error) { paginate := crdbx.Paginate(pagination, crdbx.ContextAsOfSystemTime(ctx, "-1m")) q := fmt.Sprintf( @@ -227,3 +236,191 @@ func (gs *groupService) DeleteGroup(ctx context.Context, id gidx.PrefixedID) err return err } + +func (gs *groupService) AddMembers(ctx context.Context, groupID gidx.PrefixedID, subjects ...gidx.PrefixedID) error { + if len(subjects) == 0 { + return nil + } + + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + if _, err := gs.fetchGroupByID(ctx, groupID); err != nil { + return err + } + + vals := make([]string, 0, len(subjects)) + params := make([]any, 0, len(subjects)+1) + params = append(params, groupID) + + const placeholderOffset = 2 + + for i, subj := range subjects { + vals = append(vals, fmt.Sprintf("($1, $%d)", i+placeholderOffset)) + params = append(params, subj) + } + + q := fmt.Sprintf( + "UPSERT INTO group_members (%s, %s) VALUES %s", + groupMemberCols.GroupID, groupMemberCols.SubjectID, + strings.Join(vals, ", "), + ) + + _, err = tx.ExecContext(ctx, q, params...) + if err != nil { + fmt.Println(err.Error()) + } + + return err +} + +func (gs *groupService) ListMembers(ctx context.Context, groupID gidx.PrefixedID, pagination crdbx.Paginator) ([]gidx.PrefixedID, error) { + paginate := crdbx.Paginate(pagination, crdbx.ContextAsOfSystemTime(ctx, "-1m")) + + if _, err := gs.fetchGroupByID(ctx, groupID); err != nil { + return nil, err + } + + q := fmt.Sprintf( + "SELECT %s FROM group_members %s WHERE %s = $1 %s %s %s", + groupMemberCols.SubjectID, paginate.AsOfSystemTime(), groupMemberCols.GroupID, + paginate.AndWhere(2), //nolint:gomnd + paginate.OrderClause(), + paginate.LimitClause(), + ) + + rows, err := gs.db.QueryContext(ctx, q, paginate.Values(groupID)...) + if err != nil { + return nil, err + } + + defer rows.Close() + + var members []gidx.PrefixedID + + for rows.Next() { + var member gidx.PrefixedID + + if err := rows.Scan(&member); err != nil { + return nil, err + } + + members = append(members, member) + } + + return members, nil +} + +func (gs *groupService) RemoveMember(ctx context.Context, groupID gidx.PrefixedID, subject gidx.PrefixedID) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + if _, err := gs.fetchGroupByID(ctx, groupID); err != nil { + return err + } + + q := fmt.Sprintf( + "DELETE FROM group_members WHERE %s = $1 AND %s = $2", + groupMemberCols.GroupID, groupMemberCols.SubjectID, + ) + + res, err := tx.ExecContext(ctx, q, groupID, subject) + if err != nil { + return err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return err + } else if rowsAffected == 0 { + return types.ErrGroupMemberNotFound + } + + return err +} + +func (gs *groupService) ReplaceMembers(ctx context.Context, groupID gidx.PrefixedID, subjects ...gidx.PrefixedID) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + if _, err := gs.fetchGroupByID(ctx, groupID); err != nil { + return err + } + + delq := fmt.Sprintf( + "DELETE FROM group_members WHERE %s = $1", + groupMemberCols.GroupID, + ) + + _, err = tx.ExecContext(ctx, delq, groupID) + if err != nil { + return err + } + + return gs.AddMembers(ctx, groupID, subjects...) +} + +func (gs *groupService) ListGroupsBySubject(ctx context.Context, subject gidx.PrefixedID, pagination crdbx.Paginator) (types.Groups, error) { + paginate := crdbx.Paginate(pagination, crdbx.ContextAsOfSystemTime(ctx, "-1m")) + + const ( + membersTable = "group_members" + groupsTable = "groups" + ) + + q := fmt.Sprintf( + `SELECT %s FROM %s LEFT JOIN %s ON %s %s WHERE %s = $1 %s %s %s`, + // SELECT + strings.Join([]string{ + fmt.Sprintf("DISTINCT(%s.%s)", membersTable, groupMemberCols.GroupID), + fmt.Sprintf("%s.%s", groupsTable, groupCols.Name), + fmt.Sprintf("%s.%s", groupsTable, groupCols.Description), + fmt.Sprintf("%s.%s", groupsTable, groupCols.OwnerID), + }, ", "), + // FROM + membersTable, + // LEFT JOIN + groupsTable, + // ON + fmt.Sprintf( + "%s.%s = %s.%s", + groupsTable, groupCols.ID, + membersTable, groupMemberCols.GroupID, + ), + // as of system time + paginate.AsOfSystemTime(), + // WHERE + fmt.Sprintf("%s.%s", membersTable, groupMemberCols.SubjectID), + // Pagination + paginate.AndWhere(2), //nolint:gomnd + paginate.OrderClause(), + paginate.LimitClause(), + ) + + rows, err := gs.db.QueryContext(ctx, q, paginate.Values(subject)...) + if err != nil { + return nil, err + } + + defer rows.Close() + + var groups types.Groups + + for rows.Next() { + g := &types.Group{} + + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID); err != nil { + return nil, err + } + + groups = append(groups, g) + } + + return groups, nil +} diff --git a/internal/storage/migrations/0005_group_membership.sql b/internal/storage/migrations/0005_group_membership.sql new file mode 100644 index 00000000..4d71e101 --- /dev/null +++ b/internal/storage/migrations/0005_group_membership.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE group_members ( + group_id STRING NOT NULL REFERENCES groups(id), + subject_id STRING NOT NULL, + + index group_memberships_subject_id_index (subject_id), + primary key (group_id, subject_id) +); + +-- +goose Down +DROP TABLE group_members; diff --git a/internal/types/errors.go b/internal/types/errors.go index 0685fb6f..ba800f58 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -36,6 +36,9 @@ var ( // ErrGroupNameEmpty is returned if the group name is empty. ErrGroupNameEmpty = fmt.Errorf("%w: group name is empty", ErrInvalidArgument) + + // ErrGroupMemberNotFound is returned if the group member doesn't exist. + ErrGroupMemberNotFound = fmt.Errorf("%w: group member not found", ErrNotFound) ) // ErrorInvalidTokenRequest represents an error where an access token request failed. diff --git a/internal/types/groups.go b/internal/types/groups.go index baae58af..dad65a91 100644 --- a/internal/types/groups.go +++ b/internal/types/groups.go @@ -43,11 +43,28 @@ type GroupUpdate struct { // GroupService represents a service for managing groups. type GroupService interface { + // CreateGroup creates a new group. CreateGroup(ctx context.Context, group Group) (*Group, error) + // GetGroupByID retrieves a group by its ID. GetGroupByID(ctx context.Context, id gidx.PrefixedID) (*Group, error) - ListGroups(ctx context.Context, ownerID gidx.PrefixedID, pagination crdbx.Paginator) (Groups, error) + // UpdateGroup updates a group. UpdateGroup(ctx context.Context, id gidx.PrefixedID, update GroupUpdate) (*Group, error) + // DeleteGroup deletes a group. DeleteGroup(ctx context.Context, id gidx.PrefixedID) error + + // ListGroupsByOwner retrieves a list of groups owned by an OU. + ListGroupsByOwner(ctx context.Context, ownerID gidx.PrefixedID, pagination crdbx.Paginator) (Groups, error) + // ListGroupsBySubject retrieves a list of groups that a subject is a member of. + ListGroupsBySubject(ctx context.Context, subject gidx.PrefixedID, pagination crdbx.Paginator) (Groups, error) + + // AddMembers adds subjects to a group. + AddMembers(ctx context.Context, groupID gidx.PrefixedID, subjects ...gidx.PrefixedID) error + // ListMembers retrieves a list of subjects in a group. + ListMembers(ctx context.Context, groupID gidx.PrefixedID, pagination crdbx.Paginator) ([]gidx.PrefixedID, error) + // RemoveMember removes a subject from a group. + RemoveMember(ctx context.Context, groupID gidx.PrefixedID, subject gidx.PrefixedID) error + // ReplaceMembers replaces the members of a group with a new set of subjects. + ReplaceMembers(ctx context.Context, groupID gidx.PrefixedID, subjects ...gidx.PrefixedID) error } // Groups represents a list of groups @@ -68,3 +85,14 @@ func (g Groups) ToV1Groups() ([]v1.Group, error) { return out, nil } + +// ToPrefixedIDs converts a list of groups to a list of group IDs. +func (g Groups) ToPrefixedIDs() []gidx.PrefixedID { + out := make([]gidx.PrefixedID, len(g)) + + for i, group := range g { + out[i] = group.ID + } + + return out +} diff --git a/internal/userinfo/handler.go b/internal/userinfo/handler.go index c0750757..f0cd144d 100644 --- a/internal/userinfo/handler.go +++ b/internal/userinfo/handler.go @@ -3,22 +3,32 @@ package userinfo import ( + "fmt" "net/http" + "strconv" "github.com/labstack/echo/v4" + "go.infratographer.com/identity-api/internal/crdbx" "go.infratographer.com/identity-api/internal/types" + v1 "go.infratographer.com/identity-api/pkg/api/v1" "go.infratographer.com/x/echojwtx" "go.infratographer.com/x/gidx" ) +// Store is an interface providing userinfo and group services +type Store interface { + types.UserInfoService + types.GroupService +} + // Handler provides the endpoint for /userinfo type Handler struct { - store types.UserInfoService + store Store } // NewHandler creates a UserInfo handler with the storage engine -func NewHandler(userInfoSvc types.UserInfoService) (*Handler, error) { +func NewHandler(userInfoSvc Store) (*Handler, error) { return &Handler{ store: userInfoSvc, }, nil @@ -42,7 +52,57 @@ func (h *Handler) handle(ctx echo.Context) error { return ctx.JSON(http.StatusOK, info) } +// ListUserGroups expects an authenticated request using a STS token and +// returns the groups the user is a member of. +func (h *Handler) listUserGroups(ctx echo.Context) error { + fullSubject := echojwtx.Actor(ctx) + + resourceID, err := gidx.Parse(fullSubject) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid subject").SetInternal(err) + } + + cursor := ctx.QueryParam("cursor") + limit := ctx.QueryParam("limit") + + pagination := v1.ListUserGroupsParams{} + + if cursor != "" { + c := crdbx.Cursor(cursor) + pagination.Cursor = &c + } + + if limit != "" { + limitInt, err := strconv.Atoi(limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid limit: %s", limit)) + } + + l := crdbx.Limit(limitInt) + pagination.Limit = &l + } + + groups, err := h.store.ListGroupsBySubject(ctx.Request().Context(), resourceID, pagination) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) + } + + resp := groups.ToPrefixedIDs() + + collection := v1.GroupIDCollection{ + GroupIDs: resp, + Pagination: v1.Pagination{}, + } + + if err := pagination.SetPagination(&collection); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err) + } + + return ctx.JSON(http.StatusOK, collection) +} + // Routes registers the userinfo handler in a echo.Group func (h *Handler) Routes(rg *echo.Group) { rg.GET("userinfo", h.handle) + rg.GET("userinfo/groups", h.listUserGroups) } diff --git a/openapi-v1.yaml b/openapi-v1.yaml index 2e78fe7b..bd1f33de 100644 --- a/openapi-v1.yaml +++ b/openapi-v1.yaml @@ -282,6 +282,27 @@ paths: schema: $ref: '#/components/schemas/User' + /api/v1/users/{userID}/groups: + get: + tags: + - Users + summary: Lists groups by user id + description: Lists groups by user id. + operationId: ListUserGroups + parameters: + - in: path + name: userID + required: true + description: User ID + schema: + type: string + x-go-type: gidx.PrefixedID + - $ref: '#/components/parameters/pageCursor' + - $ref: '#/components/parameters/pageLimit' + responses: + '200': + $ref: '#/components/responses/GroupIDCollection' + /api/v1/groups/{groupID}: delete: tags: @@ -334,6 +355,82 @@ paths: application/json: schema: $ref: '#/components/schemas/Group' + + /api/v1/groups/{groupID}/members: + get: + tags: + - Groups + summary: Gets members of a Group + description: Gets the members of a group by ID. + operationId: listGroupMembers + parameters: + - $ref: '#/components/parameters/groupID' + - $ref: '#/components/parameters/pageCursor' + - $ref: '#/components/parameters/pageLimit' + responses: + '200': + $ref: '#/components/responses/GroupMemberCollection' + put: + tags: + - Groups + summary: Replaces members of a Group + description: Replaces the members of a group by ID. + operationId: replaceGroupMembers + parameters: + - $ref: '#/components/parameters/groupID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddGroupMembers' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AddGroupMembersResponse' + post: + tags: + - Groups + summary: Adds a member to a Group + description: Adds a member to a group by ID. + operationId: addGroupMembers + parameters: + - $ref: '#/components/parameters/groupID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddGroupMembers' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AddGroupMembersResponse' + + /api/v1/groups/{groupID}/members/{subjectID}: + delete: + tags: + - Groups + summary: Removes a member from a Group + description: Removes a member from a group by their ID. + operationId: removeGroupMember + parameters: + - $ref: '#/components/parameters/groupID' + - $ref: '#/components/parameters/subjectID' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + components: schemas: DeleteResponse: @@ -522,7 +619,6 @@ components: - id - name - owner - - members properties: id: x-go-name: ID @@ -540,28 +636,26 @@ components: type: string x-go-type: gidx.PrefixedID description: ID of the owner of the group - members: + + AddGroupMembers: + required: + - member_ids + properties: + member_ids: type: array + x-go-name: MemberIDs items: type: string x-go-type: gidx.PrefixedID - description: IDs of the members of the group - created_at: - type: string - format: date-time - description: time the group was created - created_by: - type: string - x-go-type: gidx.PrefixedID - description: ID of the user who created the group - updated_at: - type: string - format: date-time - description: time the group was last updated - updated_by: - type: string - x-go-type: gidx.PrefixedID - description: ID of the user who last updated the group + description: IDs of the members to add to the group + + AddGroupMembersResponse: + required: + - success + properties: + success: + type: boolean + description: true if the members were added successfully parameters: ownerID: @@ -597,6 +691,17 @@ components: x-go-type: gidx.PrefixedID x-go-type-import: path: go.infratographer.com/x/gidx + subjectID: + description: id of a subject + in: path + name: subjectID + x-go-name: SubjectID + required: true + schema: + type: string + x-go-type: gidx.PrefixedID + x-go-type-import: + path: go.infratographer.com/x/gidx pageCursor: description: the cursor to the results to return in: query @@ -684,3 +789,49 @@ components: $ref: '#/components/schemas/Group' pagination: $ref: '#/components/schemas/Pagination' + GroupIDCollection: + description: a collection of group ids + content: + application/json: + schema: + type: object + required: + - group_ids + - pagination + properties: + group_ids: + type: array + x-go-name: GroupIDs + items: + type: string + x-go-type: gidx.PrefixedID + x-go-type-import: + pagination: + $ref: '#/components/schemas/Pagination' + GroupMemberCollection: + description: a collection of group members + content: + application/json: + schema: + type: object + required: + - member_ids + - group_id + - pagination + properties: + group_id: + type: string + x-go-name: GroupID + x-go-type: gidx.PrefixedID + x-go-type-import: + path: go.infratographer.com/x/gidx + member_ids: + type: array + x-go-name: MemberIDs + items: + type: string + x-go-type: gidx.PrefixedID + x-go-type-import: + path: go.infratographer.com/x/gidx + pagination: + $ref: '#/components/schemas/Pagination' diff --git a/pkg/api/v1/paginate_group_members.go b/pkg/api/v1/paginate_group_members.go new file mode 100644 index 00000000..5076f547 --- /dev/null +++ b/pkg/api/v1/paginate_group_members.go @@ -0,0 +1,44 @@ +package v1 + +import "go.infratographer.com/identity-api/internal/crdbx" + +var _ crdbx.Paginator = ListGroupMembersParams{} + +// GetCursor implements crdbx.Paginator returning the cursor. +func (p ListGroupMembersParams) GetCursor() *crdbx.Cursor { + return p.Cursor +} + +// GetLimit implements crdbx.Paginator returning requested limit. +func (p ListGroupMembersParams) GetLimit() int { + if p.Limit == nil { + return 0 + } + + return *p.Limit +} + +// GetOnlyFields implements crdbx.Paginator setting the only permitted field to `subject_id`. +func (p ListGroupMembersParams) GetOnlyFields() []string { + return []string{"subject_id"} +} + +// SetPagination sets the pagination on the provided collection. +func (p ListGroupMembersParams) SetPagination(collection *GroupMemberCollection) error { + collection.Pagination.Limit = crdbx.Limit(p.GetLimit()) + + if count := len(collection.MemberIDs); count != 0 && count == collection.Pagination.Limit { + last := collection.MemberIDs[count-1] + + cursor, err := crdbx.NewCursor( + "subject_id", last.String(), + ) + if err != nil { + return err + } + + collection.Pagination.Next = cursor + } + + return nil +} diff --git a/pkg/api/v1/paginate_user_groups.go b/pkg/api/v1/paginate_user_groups.go new file mode 100644 index 00000000..cd5681c9 --- /dev/null +++ b/pkg/api/v1/paginate_user_groups.go @@ -0,0 +1,40 @@ +package v1 + +import "go.infratographer.com/identity-api/internal/crdbx" + +var _ crdbx.Paginator = ListUserGroupsParams{} + +// GetCursor implements crdbx.Paginator returning the cursor. +func (p ListUserGroupsParams) GetCursor() *crdbx.Cursor { + return p.Cursor +} + +// GetLimit implements crdbx.Paginator returning requested limit. +func (p ListUserGroupsParams) GetLimit() int { + if p.Limit == nil { + return 0 + } + + return *p.Limit +} + +// GetOnlyFields implements crdbx.Paginator setting the only permitted field to `group_id`. +func (p ListUserGroupsParams) GetOnlyFields() []string { + return []string{"group_id"} +} + +// SetPagination sets the pagination on the provided collection. +func (p ListUserGroupsParams) SetPagination(collection *GroupIDCollection) error { + collection.Pagination.Limit = crdbx.Limit(p.GetLimit()) + + if count := len(collection.GroupIDs); count != 0 && count == collection.Pagination.Limit { + cursor, err := crdbx.NewCursor("group_id", collection.GroupIDs[count-1].String()) + if err != nil { + return err + } + + collection.Pagination.Next = cursor + } + + return nil +} diff --git a/pkg/api/v1/types.gen.go b/pkg/api/v1/types.gen.go index 244e8333..6ab5b0f3 100644 --- a/pkg/api/v1/types.gen.go +++ b/pkg/api/v1/types.gen.go @@ -11,13 +11,24 @@ import ( "net/url" "path" "strings" - "time" "github.com/getkin/kin-openapi/openapi3" "go.infratographer.com/identity-api/internal/crdbx" "go.infratographer.com/x/gidx" ) +// AddGroupMembers defines model for AddGroupMembers. +type AddGroupMembers struct { + // MemberIds IDs of the members to add to the group + MemberIDs []gidx.PrefixedID `json:"member_ids"` +} + +// AddGroupMembersResponse defines model for AddGroupMembersResponse. +type AddGroupMembersResponse struct { + // Success true if the members were added successfully + Success bool `json:"success"` +} + // CreateGroup defines model for CreateGroup. type CreateGroup struct { // Description a description for the group @@ -59,32 +70,17 @@ type DeleteResponse struct { // Group defines model for Group. type Group struct { - // CreatedAt time the group was created - CreatedAt *time.Time `json:"created_at,omitempty"` - - // CreatedBy ID of the user who created the group - CreatedBy *gidx.PrefixedID `json:"created_by,omitempty"` - // Description a description for the group Description *string `json:"description,omitempty"` // Id ID of the group ID gidx.PrefixedID `json:"id"` - // Members IDs of the members of the group - Members []gidx.PrefixedID `json:"members"` - // Name a name for the group Name string `json:"name"` // OwnerId ID of the owner of the group OwnerID *gidx.PrefixedID `json:"owner_id,omitempty"` - - // UpdatedAt time the group was last updated - UpdatedAt *time.Time `json:"updated_at,omitempty"` - - // UpdatedBy ID of the user who last updated the group - UpdatedBy *gidx.PrefixedID `json:"updated_by,omitempty"` } // Issuer defines model for Issuer. @@ -186,6 +182,9 @@ type PageCursor = crdbx.Cursor // PageLimit defines model for pageLimit. type PageLimit = int +// SubjectID defines model for subjectID. +type SubjectID = gidx.PrefixedID + // GroupCollection defines model for GroupCollection. type GroupCollection struct { Groups []Group `json:"groups"` @@ -194,6 +193,23 @@ type GroupCollection struct { Pagination Pagination `json:"pagination"` } +// GroupIDCollection defines model for GroupIDCollection. +type GroupIDCollection struct { + GroupIDs []gidx.PrefixedID `json:"group_ids"` + + // Pagination collection response pagination + Pagination Pagination `json:"pagination"` +} + +// GroupMemberCollection defines model for GroupMemberCollection. +type GroupMemberCollection struct { + GroupID gidx.PrefixedID `json:"group_id"` + MemberIDs []gidx.PrefixedID `json:"member_ids"` + + // Pagination collection response pagination + Pagination Pagination `json:"pagination"` +} + // IssuerCollection defines model for IssuerCollection. type IssuerCollection struct { Issuers []Issuer `json:"issuers"` @@ -217,6 +233,15 @@ type UserCollection struct { Users []User `json:"users"` } +// ListGroupMembersParams defines parameters for ListGroupMembers. +type ListGroupMembersParams struct { + // Cursor the cursor to the results to return + Cursor *PageCursor `form:"cursor,omitempty" json:"cursor,omitempty" query:"cursor"` + + // Limit limits the response collections + Limit *PageLimit `form:"limit,omitempty" json:"limit,omitempty" query:"limit"` +} + // GetIssuerUsersParams defines parameters for GetIssuerUsers. type GetIssuerUsersParams struct { // Cursor the cursor to the results to return @@ -253,9 +278,24 @@ type ListOwnerIssuersParams struct { Limit *PageLimit `form:"limit,omitempty" json:"limit,omitempty" query:"limit"` } +// ListUserGroupsParams defines parameters for ListUserGroups. +type ListUserGroupsParams struct { + // Cursor the cursor to the results to return + Cursor *PageCursor `form:"cursor,omitempty" json:"cursor,omitempty" query:"cursor"` + + // Limit limits the response collections + Limit *PageLimit `form:"limit,omitempty" json:"limit,omitempty" query:"limit"` +} + // UpdateGroupJSONRequestBody defines body for UpdateGroup for application/json ContentType. type UpdateGroupJSONRequestBody = UpdateGroup +// AddGroupMembersJSONRequestBody defines body for AddGroupMembers for application/json ContentType. +type AddGroupMembersJSONRequestBody = AddGroupMembers + +// ReplaceGroupMembersJSONRequestBody defines body for ReplaceGroupMembers for application/json ContentType. +type ReplaceGroupMembersJSONRequestBody = AddGroupMembers + // UpdateIssuerJSONRequestBody defines body for UpdateIssuer for application/json ContentType. type UpdateIssuerJSONRequestBody = IssuerUpdate @@ -271,43 +311,47 @@ type CreateIssuerJSONRequestBody = CreateIssuer // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file