From c3c9802b8a2a16077793498b995b6a632f4641c5 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sat, 4 Apr 2026 02:42:29 +0100 Subject: Cleanup admin api --- REUSE.toml | 1 + cmd/mirumd/main.go | 109 +++++--- cmd/mirumd/server_admin.go | 421 +++++++++++++++++++++++------ go.mod | 14 +- go.sum | 35 +++ internal/database/database.go | 30 ++- internal/database/organization.go | 428 ++++++++++++++++++++---------- internal/database/user.go | 290 +++++++++++++++----- internal/database/validate.go | 60 +++++ internal/database/worker.go | 146 +++++++--- proto/admin.proto | 282 ++++++++++++++------ proto/buf.lock | 6 + proto/buf.yaml | 6 + 13 files changed, 1354 insertions(+), 474 deletions(-) create mode 100644 internal/database/validate.go create mode 100644 proto/buf.lock create mode 100644 proto/buf.yaml diff --git a/REUSE.toml b/REUSE.toml index 6875d1a..f387a83 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,6 +12,7 @@ path = [ "README.md", "pkg/logo.svg", "pkg/dl/*", + "proto/buf.*" ] SPDX-FileCopyrightText = "2026 Nikolay Govorov " SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index b7210e6..b0b8368 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -22,6 +22,7 @@ import ( "dimidiumlabs/mirum/internal/forges" "connectrpc.com/connect" + "github.com/google/uuid" "dimidiumlabs/mirum/internal/protocol/pb" "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" @@ -187,12 +188,10 @@ func main() { Use: "list", Short: "List organizations", Run: func(cmd *cobra.Command, args []string) { - email, _ := cmd.Flags().GetString("user") - orgList(socketPath, email) + orgList(socketPath) }, } orgCmd.AddCommand(orgListCmd) - orgListCmd.Flags().String("user", "", "filter by user email (optional)") orgMemberAddCmd := &cobra.Command{ Use: "add-member", @@ -449,6 +448,35 @@ func adminClient(socketPath string) pbconnect.AdminClient { ) } +func cliUserRef(email string) *pb.UserRef { + return &pb.UserRef{Ref: &pb.UserRef_Email{Email: email}} +} + +func cliOrgRef(slug string) *pb.OrgRef { + return &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: slug}} +} + +func cliRoleToProto(role string) pb.Role { + switch role { + case "owner": + return pb.Role_ROLE_OWNER + case "admin": + return pb.Role_ROLE_ADMIN + case "member": + return pb.Role_ROLE_MEMBER + default: + return pb.Role_ROLE_NONE + } +} + +func cliUUID(b []byte) string { + if len(b) == 16 { + u, _ := uuid.FromBytes(b) + return u.String() + } + return fmt.Sprintf("%x", b) +} + func userCreate(socketPath, email, password string) { resp, err := adminClient(socketPath).UserCreate(context.Background(), connect.NewRequest(&pb.UserCreateRequest{ Email: email, @@ -458,13 +486,13 @@ func userCreate(socketPath, email, password string) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println(resp.Msg.Id) + fmt.Println(cliUUID(resp.Msg.Id)) } func userSetPassword(socketPath, email, password string) { - _, err := adminClient(socketPath).UserSetPassword(context.Background(), connect.NewRequest(&pb.UserSetPasswordRequest{ - Email: email, - Password: password, + _, err := adminClient(socketPath).UserUpdate(context.Background(), connect.NewRequest(&pb.UserUpdateRequest{ + User: cliUserRef(email), + Password: &password, })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -475,7 +503,7 @@ func userSetPassword(socketPath, email, password string) { func userDelete(socketPath, email string) { _, err := adminClient(socketPath).UserDelete(context.Background(), connect.NewRequest(&pb.UserDeleteRequest{ - Email: email, + User: cliUserRef(email), })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -500,19 +528,24 @@ func workerAdd(socketPath, pubkeyB64 string) { fmt.Fprintln(os.Stderr, "not an ed25519 key") os.Exit(1) } - resp, err := adminClient(socketPath).WorkerAdd(context.Background(), connect.NewRequest(&pb.WorkerAddRequest{ + resp, err := adminClient(socketPath).WorkerCreate(context.Background(), connect.NewRequest(&pb.WorkerCreateRequest{ PublicKey: edKey, })) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println(resp.Msg.Id) + fmt.Println(cliUUID(resp.Msg.Id)) } func workerRevoke(socketPath, id string) { - _, err := adminClient(socketPath).WorkerRevoke(context.Background(), connect.NewRequest(&pb.WorkerRevokeRequest{ - Id: id, + uid, err := uuid.Parse(id) + if err != nil { + fmt.Fprintln(os.Stderr, "invalid uuid:", err) + os.Exit(1) + } + _, err = adminClient(socketPath).WorkerDelete(context.Background(), connect.NewRequest(&pb.WorkerDeleteRequest{ + Id: uid[:], })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -529,27 +562,27 @@ func workerList(socketPath string) { } for _, w := range resp.Msg.Workers { created := w.CreatedAt.AsTime().Format(time.DateOnly) - fmt.Printf("%s\t%s\t%s\n", w.Id, base64.StdEncoding.EncodeToString(w.PublicKey), created) + fmt.Printf("%s\t%s\t%s\n", cliUUID(w.Id), base64.StdEncoding.EncodeToString(w.PublicKey), created) } } func orgCreate(socketPath, name, slug string, public bool, ownerEmail string) { resp, err := adminClient(socketPath).OrgCreate(context.Background(), connect.NewRequest(&pb.OrgCreateRequest{ - Name: name, - Slug: slug, - Public: public, - OwnerEmail: ownerEmail, + Name: name, + Slug: slug, + Public: public, + Owner: cliUserRef(ownerEmail), })) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println(resp.Msg.Id) + fmt.Println(cliUUID(resp.Msg.Id)) } func orgDelete(socketPath, slug string) { _, err := adminClient(socketPath).OrgDelete(context.Background(), connect.NewRequest(&pb.OrgDeleteRequest{ - Slug: slug, + Org: cliOrgRef(slug), })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -559,10 +592,10 @@ func orgDelete(socketPath, slug string) { } func orgRename(socketPath, slug, newName, newSlug string) { - _, err := adminClient(socketPath).OrgRename(context.Background(), connect.NewRequest(&pb.OrgRenameRequest{ - Slug: slug, - NewName: newName, - NewSlug: newSlug, + _, err := adminClient(socketPath).OrgUpdate(context.Background(), connect.NewRequest(&pb.OrgUpdateRequest{ + Org: cliOrgRef(slug), + Name: &newName, + Slug: &newSlug, })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -571,12 +604,8 @@ func orgRename(socketPath, slug, newName, newSlug string) { fmt.Println("ok") } -func orgList(socketPath, email string) { - req := &pb.OrgListRequest{} - if email != "" { - req.UserEmail = &email - } - resp, err := adminClient(socketPath).OrgList(context.Background(), connect.NewRequest(req)) +func orgList(socketPath string) { + resp, err := adminClient(socketPath).OrgList(context.Background(), connect.NewRequest(&pb.OrgListRequest{})) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -592,9 +621,9 @@ func orgList(socketPath, email string) { func orgMemberAdd(socketPath, orgSlug, email, role string) { _, err := adminClient(socketPath).OrgMemberAdd(context.Background(), connect.NewRequest(&pb.OrgMemberAddRequest{ - OrgSlug: orgSlug, - Email: email, - Role: role, + Org: cliOrgRef(orgSlug), + User: cliUserRef(email), + Role: cliRoleToProto(role), })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -605,8 +634,8 @@ func orgMemberAdd(socketPath, orgSlug, email, role string) { func orgMemberRemove(socketPath, orgSlug, email string) { _, err := adminClient(socketPath).OrgMemberRemove(context.Background(), connect.NewRequest(&pb.OrgMemberRemoveRequest{ - OrgSlug: orgSlug, - Email: email, + Org: cliOrgRef(orgSlug), + User: cliUserRef(email), })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -616,10 +645,10 @@ func orgMemberRemove(socketPath, orgSlug, email string) { } func orgMemberSetRole(socketPath, orgSlug, email, role string) { - _, err := adminClient(socketPath).OrgMemberSetRole(context.Background(), connect.NewRequest(&pb.OrgMemberSetRoleRequest{ - OrgSlug: orgSlug, - Email: email, - Role: role, + _, err := adminClient(socketPath).OrgMemberUpdate(context.Background(), connect.NewRequest(&pb.OrgMemberUpdateRequest{ + Org: cliOrgRef(orgSlug), + User: cliUserRef(email), + Role: cliRoleToProto(role), })) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -630,13 +659,13 @@ func orgMemberSetRole(socketPath, orgSlug, email, role string) { func orgMemberList(socketPath, orgSlug string) { resp, err := adminClient(socketPath).OrgMemberList(context.Background(), connect.NewRequest(&pb.OrgMemberListRequest{ - OrgSlug: orgSlug, + Org: cliOrgRef(orgSlug), })) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } for _, m := range resp.Msg.Members { - fmt.Printf("%s\t%s\t%s\n", m.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly)) + fmt.Printf("%s\t%s\t%s\n", m.User.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly)) } } diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go index 2962887..94ab144 100644 --- a/cmd/mirumd/server_admin.go +++ b/cmd/mirumd/server_admin.go @@ -5,11 +5,13 @@ package main import ( "context" - "crypto/ed25519" + "errors" "fmt" "net/http" "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/google/uuid" "google.golang.org/protobuf/types/known/timestamppb" "dimidiumlabs/mirum/internal/database" @@ -19,7 +21,9 @@ import ( func NewAdminServer(srv *server) *http.Server { as := &adminService{srv: srv} - path, handler := pbconnect.NewAdminHandler(as) + path, handler := pbconnect.NewAdminHandler(as, + connect.WithInterceptors(validate.NewInterceptor()), + ) mux := http.NewServeMux() mux.Handle(path, handler) return &http.Server{Handler: mux} @@ -30,143 +34,388 @@ type adminService struct { srv *server } +// --- Error mapping --- + +func mapErr(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, database.ErrUserNotFound), + errors.Is(err, database.ErrOrgNotFound), + errors.Is(err, database.ErrWorkerNotFound), + errors.Is(err, database.ErrNotMember): + return connect.NewError(connect.CodeNotFound, err) + case errors.Is(err, database.ErrAlreadyMember), + errors.Is(err, database.ErrSlugTaken), + errors.Is(err, database.ErrEmailTaken): + return connect.NewError(connect.CodeAlreadyExists, err) + case errors.Is(err, database.ErrLastOwner), + errors.Is(err, database.ErrSoleOwner): + return connect.NewError(connect.CodeFailedPrecondition, err) + case errors.Is(err, database.ErrInvalidSlug), + errors.Is(err, database.ErrInvalidRole): + return connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, database.ErrFilterNotImplemented): + return connect.NewError(connect.CodeUnimplemented, err) + default: + return connect.NewError(connect.CodeInternal, fmt.Errorf("internal error")) + } +} + +// --- Ref converters --- + +func userRef(r *pb.UserRef) database.UserRef { + switch v := r.GetRef().(type) { + case *pb.UserRef_Id: + return database.UserByID(uuid.UUID(v.Id)) + case *pb.UserRef_Email: + return database.UserByEmail(v.Email) + default: + return database.UserByID(uuid.Nil) + } +} + +func orgRef(r *pb.OrgRef) database.OrgRef { + switch v := r.GetRef().(type) { + case *pb.OrgRef_Id: + return database.OrgByID(uuid.UUID(v.Id)) + case *pb.OrgRef_Slug: + return database.OrgBySlug(v.Slug) + default: + return database.OrgByID(uuid.Nil) + } +} + +// --- Role converters --- + +var roleToString = map[pb.Role]string{ + pb.Role_ROLE_OWNER: "owner", + pb.Role_ROLE_ADMIN: "admin", + pb.Role_ROLE_MEMBER: "member", +} + +var roleToProto = map[string]pb.Role{ + "owner": pb.Role_ROLE_OWNER, + "admin": pb.Role_ROLE_ADMIN, + "member": pb.Role_ROLE_MEMBER, +} + +// --- Page helpers --- + +const ( + defaultPageSize = 50 + maxPageSize = 200 +) + +func pageParams(p *pb.PageRequest) (cursor uuid.UUID, limit int) { + limit = defaultPageSize + if p != nil { + if p.PageSize > 0 && int(p.PageSize) < maxPageSize { + limit = int(p.PageSize) + } else if int(p.PageSize) >= maxPageSize { + limit = maxPageSize + } + if len(p.Cursor) == 16 { + cursor = uuid.UUID(p.Cursor) + } + } + return +} + +func pageResponse(items int, limit int, lastID uuid.UUID, total int) *pb.PageResponse { + resp := &pb.PageResponse{TotalCount: int32(total)} + if items == limit { + resp.NextCursor = lastID[:] + } + return resp +} + +// --- Proto converters --- + +func userToProto(u database.User) *pb.User { + return &pb.User{ + Id: u.ID[:], Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), + } +} + +func orgToProto(o database.Organization) *pb.Org { + return &pb.Org{ + Id: o.ID[:], Name: o.Name, Slug: o.Slug, + Public: o.Public, CreatedAt: timestamppb.New(o.CreatedAt), + } +} + +func memberToProto(m database.OrgMember) *pb.OrgMemberInfo { + return &pb.OrgMemberInfo{ + User: userToProto(m.User), Role: roleToProto[m.Role], + JoinedAt: timestamppb.New(m.JoinedAt), + } +} + +func workerToProto(w database.Worker) *pb.Worker { + pw := &pb.Worker{ + Id: w.ID[:], PublicKey: w.PublicKey, CreatedAt: timestamppb.New(w.CreatedAt), + } + if w.OrgID != nil { + pw.OrgId = w.OrgID[:] + } + return pw +} + +// --- User handlers --- + func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.UserCreateRequest]) (*connect.Response[pb.UserCreateResponse], error) { id, err := a.srv.db.UserCreate(ctx, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) if err != nil { - return nil, err + return nil, mapErr(err) } - return connect.NewResponse(&pb.UserCreateResponse{Id: id}), nil + return connect.NewResponse(&pb.UserCreateResponse{Id: id[:]}), nil +} + +func (a *adminService) UserGet(ctx context.Context, req *connect.Request[pb.UserGetRequest]) (*connect.Response[pb.UserGetResponse], error) { + u, err := a.srv.db.GetUser(ctx, userRef(req.Msg.User)) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&pb.UserGetResponse{User: userToProto(*u)}), nil +} + +func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.UserListRequest]) (*connect.Response[pb.UserListResponse], error) { + cursor, limit := pageParams(req.Msg.Page) + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + users, total, err := a.srv.db.ListUsers(ctx, cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*pb.User, len(users)) + for i := range users { + out[i] = userToProto(users[i]) + } + + var lastID uuid.UUID + if len(users) > 0 { + lastID = users[len(users)-1].ID + } + + return connect.NewResponse(&pb.UserListResponse{ + Page: pageResponse(len(users), limit, lastID, total), + Users: out, + }), nil } -func (a *adminService) UserSetPassword(ctx context.Context, req *connect.Request[pb.UserSetPasswordRequest]) (*connect.Response[pb.UserSetPasswordResponse], error) { - if err := a.srv.db.UserSetPassword(ctx, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { - return nil, err +func (a *adminService) UserUpdate(ctx context.Context, req *connect.Request[pb.UserUpdateRequest]) (*connect.Response[pb.UserUpdateResponse], error) { + if err := a.srv.db.UserUpdate(ctx, userRef(req.Msg.User), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { + return nil, mapErr(err) } - return connect.NewResponse(&pb.UserSetPasswordResponse{}), nil + return connect.NewResponse(&pb.UserUpdateResponse{}), nil } func (a *adminService) UserDelete(ctx context.Context, req *connect.Request[pb.UserDeleteRequest]) (*connect.Response[pb.UserDeleteResponse], error) { - if err := a.srv.db.UserDelete(ctx, req.Msg.Email); err != nil { - return nil, err + if err := a.srv.db.UserDelete(ctx, userRef(req.Msg.User)); err != nil { + return nil, mapErr(err) } return connect.NewResponse(&pb.UserDeleteResponse{}), nil } -func (a *adminService) WorkerAdd(ctx context.Context, req *connect.Request[pb.WorkerAddRequest]) (*connect.Response[pb.WorkerAddResponse], error) { - if len(req.Msg.PublicKey) != ed25519.PublicKeySize { - return nil, fmt.Errorf("invalid public key: expected %d bytes, got %d", ed25519.PublicKeySize, len(req.Msg.PublicKey)) +// --- Org handlers --- + +func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.OrgCreateRequest]) (*connect.Response[pb.OrgCreateResponse], error) { + slug, err := database.ValidateSlug(req.Msg.Slug) + if err != nil { + return nil, mapErr(err) } - id, err := a.srv.db.AddWorker(ctx, req.Msg.PublicKey) + id, err := a.srv.db.CreateOrganization(ctx, req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner)) if err != nil { - return nil, err + return nil, mapErr(err) } - return connect.NewResponse(&pb.WorkerAddResponse{Id: id}), nil + return connect.NewResponse(&pb.OrgCreateResponse{Id: id[:]}), nil } -func (a *adminService) WorkerRevoke(ctx context.Context, req *connect.Request[pb.WorkerRevokeRequest]) (*connect.Response[pb.WorkerRevokeResponse], error) { - if err := a.srv.db.WorkerRevoke(ctx, req.Msg.Id); err != nil { - return nil, err +func (a *adminService) OrgGet(ctx context.Context, req *connect.Request[pb.OrgGetRequest]) (*connect.Response[pb.OrgGetResponse], error) { + o, err := a.srv.db.GetOrg(ctx, orgRef(req.Msg.Org)) + if err != nil { + return nil, mapErr(err) } - return connect.NewResponse(&pb.WorkerRevokeResponse{}), nil + return connect.NewResponse(&pb.OrgGetResponse{Org: orgToProto(*o)}), nil } -func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.WorkerListRequest]) (*connect.Response[pb.WorkerListResponse], error) { - workers, err := a.srv.db.ListWorkers(ctx) +func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgListRequest]) (*connect.Response[pb.OrgListResponse], error) { + cursor, limit := pageParams(req.Msg.Page) + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + orgs, total, err := a.srv.db.ListOrganizations(ctx, cursor, limit, filter) if err != nil { - return nil, err - } - pbWorkers := make([]*pb.Worker, len(workers)) - for i, w := range workers { - pbWorkers[i] = &pb.Worker{ - Id: w.ID, - PublicKey: w.PublicKey, - CreatedAt: timestamppb.New(w.CreatedAt), - } + return nil, mapErr(err) + } + + out := make([]*pb.Org, len(orgs)) + for i := range orgs { + out[i] = orgToProto(orgs[i]) } - return connect.NewResponse(&pb.WorkerListResponse{Workers: pbWorkers}), nil + + var lastID uuid.UUID + if len(orgs) > 0 { + lastID = orgs[len(orgs)-1].ID + } + + return connect.NewResponse(&pb.OrgListResponse{ + Page: pageResponse(len(orgs), limit, lastID, total), + Organizations: out, + }), nil } -func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.OrgCreateRequest]) (*connect.Response[pb.OrgCreateResponse], error) { - id, err := a.srv.db.CreateOrganization(ctx, req.Msg.Name, req.Msg.Slug, req.Msg.Public, req.Msg.OwnerEmail) - if err != nil { - return nil, err +func (a *adminService) OrgUpdate(ctx context.Context, req *connect.Request[pb.OrgUpdateRequest]) (*connect.Response[pb.OrgUpdateResponse], error) { + var slug *string + if req.Msg.Slug != nil { + s, err := database.ValidateSlug(*req.Msg.Slug) + if err != nil { + return nil, mapErr(err) + } + slug = &s } - return connect.NewResponse(&pb.OrgCreateResponse{Id: id}), nil + if err := a.srv.db.UpdateOrganization(ctx, orgRef(req.Msg.Org), req.Msg.Name, slug, req.Msg.Public); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&pb.OrgUpdateResponse{}), nil } func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.OrgDeleteRequest]) (*connect.Response[pb.OrgDeleteResponse], error) { - if err := a.srv.db.DeleteOrganization(ctx, req.Msg.Slug); err != nil { - return nil, err + if err := a.srv.db.DeleteOrganization(ctx, orgRef(req.Msg.Org)); err != nil { + return nil, mapErr(err) } return connect.NewResponse(&pb.OrgDeleteResponse{}), nil } -func (a *adminService) OrgRename(ctx context.Context, req *connect.Request[pb.OrgRenameRequest]) (*connect.Response[pb.OrgRenameResponse], error) { - if err := a.srv.db.RenameOrganization(ctx, req.Msg.Slug, req.Msg.NewName, req.Msg.NewSlug); err != nil { - return nil, err +// --- OrgMember handlers --- + +func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) { + role, ok := roleToString[req.Msg.Role] + if !ok { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role")) + } + if err := a.srv.db.AddOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { + return nil, mapErr(err) } - return connect.NewResponse(&pb.OrgRenameResponse{}), nil + return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil } -func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgListRequest]) (*connect.Response[pb.OrgListResponse], error) { - var ( - orgs []database.Organization - err error - ) - if req.Msg.UserEmail != nil { - orgs, err = a.srv.db.ListUserOrganizations(ctx, *req.Msg.UserEmail) - } else { - orgs, err = a.srv.db.ListAllOrganizations(ctx) +func (a *adminService) OrgMemberGet(ctx context.Context, req *connect.Request[pb.OrgMemberGetRequest]) (*connect.Response[pb.OrgMemberGetResponse], error) { + m, err := a.srv.db.GetOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User)) + if err != nil { + return nil, mapErr(err) } + return connect.NewResponse(&pb.OrgMemberGetResponse{Member: memberToProto(*m)}), nil +} + +func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[pb.OrgMemberListRequest]) (*connect.Response[pb.OrgMemberListResponse], error) { + cursor, limit := pageParams(req.Msg.Page) + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + members, total, err := a.srv.db.ListOrgMembers(ctx, orgRef(req.Msg.Org), cursor, limit, filter) if err != nil { - return nil, err - } - pbOrgs := make([]*pb.Org, len(orgs)) - for i, o := range orgs { - pbOrgs[i] = &pb.Org{ - Id: o.ID, - Name: o.Name, - Slug: o.Slug, - Public: o.Public, - CreatedAt: timestamppb.New(o.CreatedAt), - } + return nil, mapErr(err) + } + + out := make([]*pb.OrgMemberInfo, len(members)) + for i := range members { + out[i] = memberToProto(members[i]) + } + + var lastID uuid.UUID + if len(members) > 0 { + lastID = members[len(members)-1].User.ID } - return connect.NewResponse(&pb.OrgListResponse{Organizations: pbOrgs}), nil + + return connect.NewResponse(&pb.OrgMemberListResponse{ + Page: pageResponse(len(members), limit, lastID, total), + Members: out, + }), nil } -func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) { - if err := a.srv.db.AddOrgMember(ctx, req.Msg.OrgSlug, req.Msg.Email, req.Msg.Role); err != nil { - return nil, err +func (a *adminService) OrgMemberUpdate(ctx context.Context, req *connect.Request[pb.OrgMemberUpdateRequest]) (*connect.Response[pb.OrgMemberUpdateResponse], error) { + role, ok := roleToString[req.Msg.Role] + if !ok { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role")) } - return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil + if err := a.srv.db.UpdateOrgMemberRole(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&pb.OrgMemberUpdateResponse{}), nil } func (a *adminService) OrgMemberRemove(ctx context.Context, req *connect.Request[pb.OrgMemberRemoveRequest]) (*connect.Response[pb.OrgMemberRemoveResponse], error) { - if err := a.srv.db.RemoveOrgMember(ctx, req.Msg.OrgSlug, req.Msg.Email); err != nil { - return nil, err + if err := a.srv.db.RemoveOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil { + return nil, mapErr(err) } return connect.NewResponse(&pb.OrgMemberRemoveResponse{}), nil } -func (a *adminService) OrgMemberSetRole(ctx context.Context, req *connect.Request[pb.OrgMemberSetRoleRequest]) (*connect.Response[pb.OrgMemberSetRoleResponse], error) { - if err := a.srv.db.ChangeOrgMemberRole(ctx, req.Msg.OrgSlug, req.Msg.Email, req.Msg.Role); err != nil { - return nil, err +// --- Worker handlers --- + +func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb.WorkerCreateRequest]) (*connect.Response[pb.WorkerCreateResponse], error) { + var org *database.OrgRef + if req.Msg.Org != nil { + r := orgRef(req.Msg.Org) + org = &r + } + id, err := a.srv.db.CreateWorker(ctx, req.Msg.PublicKey, org) + if err != nil { + return nil, mapErr(err) } - return connect.NewResponse(&pb.OrgMemberSetRoleResponse{}), nil + return connect.NewResponse(&pb.WorkerCreateResponse{Id: id[:]}), nil } -func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[pb.OrgMemberListRequest]) (*connect.Response[pb.OrgMemberListResponse], error) { - members, err := a.srv.db.ListOrgMembers(ctx, req.Msg.OrgSlug) +func (a *adminService) WorkerGet(ctx context.Context, req *connect.Request[pb.WorkerGetRequest]) (*connect.Response[pb.WorkerGetResponse], error) { + w, err := a.srv.db.GetWorker(ctx, uuid.UUID(req.Msg.Id)) if err != nil { - return nil, err - } - pbMembers := make([]*pb.OrgMemberInfo, len(members)) - for i, m := range members { - pbMembers[i] = &pb.OrgMemberInfo{ - Email: m.User.Email, - Role: m.Role, - JoinedAt: timestamppb.New(m.JoinedAt), - } + return nil, mapErr(err) + } + return connect.NewResponse(&pb.WorkerGetResponse{Worker: workerToProto(*w)}), nil +} + +func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.WorkerListRequest]) (*connect.Response[pb.WorkerListResponse], error) { + cursor, limit := pageParams(req.Msg.Page) + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + workers, total, err := a.srv.db.ListWorkers(ctx, cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*pb.Worker, len(workers)) + for i := range workers { + out[i] = workerToProto(workers[i]) + } + + var lastID uuid.UUID + if len(workers) > 0 { + lastID = workers[len(workers)-1].ID + } + + return connect.NewResponse(&pb.WorkerListResponse{ + Page: pageResponse(len(workers), limit, lastID, total), + Workers: out, + }), nil +} + +func (a *adminService) WorkerDelete(ctx context.Context, req *connect.Request[pb.WorkerDeleteRequest]) (*connect.Response[pb.WorkerDeleteResponse], error) { + if err := a.srv.db.DeleteWorker(ctx, uuid.UUID(req.Msg.Id)); err != nil { + return nil, mapErr(err) } - return connect.NewResponse(&pb.OrgMemberListResponse{Members: pbMembers}), nil + return connect.NewResponse(&pb.WorkerDeleteResponse{}), nil } diff --git a/go.mod b/go.mod index 62d7700..889be98 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,13 @@ module dimidiumlabs/mirum go 1.26.1 require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 connectrpc.com/connect v1.19.1 + connectrpc.com/validate v0.6.0 github.com/coreos/go-systemd/v22 v22.7.0 + github.com/google/uuid v1.6.0 + github.com/huandu/go-sqlbuilder v1.40.1 + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/tern/v2 v2.3.6 github.com/spf13/cobra v1.8.0 @@ -16,11 +21,15 @@ require ( ) require ( + buf.build/go/protovalidate v1.1.3 // indirect + cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/google/cel-go v0.27.0 // indirect + github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -31,7 +40,10 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect ) diff --git a/go.sum b/go.sum index 02f3526..931ac40 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= +buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= +connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -8,6 +16,10 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -16,14 +28,25 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= +github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= +github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.40.1 h1:Q2pNM8BAbaezO56ZzkbJNMGW/CTu+8+Qw7IxF4P3+7w= +github.com/huandu/go-sqlbuilder v1.40.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -44,6 +67,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -57,13 +82,18 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4= go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -72,11 +102,16 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 h1:jm6v6kMRpTYKxBRrDkYAitNJegUeO1Mf3Kt80obv0gg= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9/go.mod h1:LmwNphe5Afor5V3R5BppOULHOnt2mCIf+NxMd4XiygE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/database/database.go b/internal/database/database.go index a6cbaed..40940bb 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -12,10 +12,11 @@ import ( ) var ( - ErrOpen = errors.New("database: failed to open") - ErrPing = errors.New("database: failed to ping") - ErrAcquire = errors.New("database: failed to acquire connection") - ErrMigrate = errors.New("database: failed to create migrator") + ErrOpen = errors.New("database: failed to open") + ErrPing = errors.New("database: failed to ping") + ErrAcquire = errors.New("database: failed to acquire connection") + ErrMigrate = errors.New("database: failed to create migrator") + ErrFilterNotImplemented = errors.New("database: filter not implemented") ) // DB wraps a pgx connection pool. @@ -78,16 +79,6 @@ func (db *DB) Migrate(ctx context.Context) error { `DROP TABLE sessions`, ) - migrator.AppendMigration("create_workers", - `CREATE TABLE workers ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - public_key BYTEA NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - revoked_at TIMESTAMPTZ - )`, - `DROP TABLE workers`, - ) - migrator.AppendMigration("create_organizations", `CREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT uuidv7(), @@ -112,5 +103,16 @@ func (db *DB) Migrate(ctx context.Context) error { `DROP TABLE org_members`, ) + migrator.AppendMigration("create_workers", + `CREATE TABLE workers ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + org_id UUID REFERENCES organizations(id), + public_key BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ + )`, + `DROP TABLE workers`, + ) + return migrator.Migrate(ctx) } diff --git a/internal/database/organization.go b/internal/database/organization.go index 53f60e7..90a81bd 100644 --- a/internal/database/organization.go +++ b/internal/database/organization.go @@ -6,23 +6,42 @@ package database import ( "context" "errors" - "strings" "time" + "github.com/google/uuid" + sb "github.com/huandu/go-sqlbuilder" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) var ( ErrOrgNotFound = errors.New("database: organization not found") - ErrAlreadyMember = errors.New("database: user is already a member") - ErrNotMember = errors.New("database: user is not a member") - ErrLastOwner = errors.New("database: cannot remove or demote the last owner") ErrSlugTaken = errors.New("database: slug already taken") + ErrAlreadyMember = errors.New("database: already a member") + ErrNotMember = errors.New("database: not a member") + ErrLastOwner = errors.New("database: last owner") ) +// OrgRef identifies an organization by ID or slug. +type OrgRef struct { + id uuid.UUID + slug string +} + +func OrgByID(id uuid.UUID) OrgRef { return OrgRef{id: id} } +func OrgBySlug(slug string) OrgRef { return OrgRef{slug: slug} } + +func (r OrgRef) where() (string, any) { + if r.id != uuid.Nil { + return "id", r.id + } + return "slug", r.slug +} + // Organization holds info about an organization. type Organization struct { - ID string + ID uuid.UUID Name string Slug string Public bool @@ -36,105 +55,188 @@ type OrgMember struct { JoinedAt time.Time } +// resolveOrg locks and returns the org ID within a transaction. +func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (uuid.UUID, error) { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() + + sql, args := q.Select("id").From("organizations"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + ForUpdate(). + Build() + + var id uuid.UUID + if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, ErrOrgNotFound + } + + return uuid.Nil, err + } + + return id, nil +} + +// GetOrg returns an org by ref (ID or slug). +func (db *DB) GetOrg(ctx context.Context, ref OrgRef) (*Organization, error) { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() + + sql, args := q.Select("id", "name", "slug", "public", "created_at"). + From("organizations"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + Build() + + var o Organization + if err := db.Pool.QueryRow(ctx, sql, args...).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrOrgNotFound + } + + return nil, err + } + + return &o, nil +} + // CreateOrganization creates an org and adds the owner as the first member. -func (db *DB) CreateOrganization(ctx context.Context, name, slug string, public bool, ownerEmail string) (string, error) { +func (db *DB) CreateOrganization(ctx context.Context, name, slug string, public bool, owner UserRef) (uuid.UUID, error) { tx, err := db.Pool.Begin(ctx) if err != nil { - return "", err + return uuid.Nil, err } defer tx.Rollback(ctx) - var userID string - if err := tx.QueryRow(ctx, - `SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL`, ownerEmail, - ).Scan(&userID); err != nil { - return "", ErrUserNotFound + userID, err := resolveUser(ctx, tx, owner) + if err != nil { + return uuid.Nil, err } - var orgID string + var orgID uuid.UUID if err := tx.QueryRow(ctx, - `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`, name, slug, public, + `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`, + name, slug, public, ).Scan(&orgID); err != nil { - return "", err + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return uuid.Nil, ErrSlugTaken + } + + return uuid.Nil, err } if _, err := tx.Exec(ctx, - `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, orgID, userID, + `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, + orgID, userID, ); err != nil { - return "", err + return uuid.Nil, err } return orgID, tx.Commit(ctx) } -// DeleteOrganization soft-deletes an org and removes all members. -func (db *DB) DeleteOrganization(ctx context.Context, slug string) error { +// UpdateOrganization updates an org's name, slug, and/or public flag. +func (db *DB) UpdateOrganization(ctx context.Context, ref OrgRef, name *string, slug *string, public *bool) error { + if name == nil && slug == nil && public == nil { + return nil + } + tx, err := db.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - var orgID string - if err := tx.QueryRow(ctx, - `UPDATE organizations SET slug = id::text, deleted_at = now() - WHERE slug = $1 AND deleted_at IS NULL RETURNING id`, slug, - ).Scan(&orgID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrOrgNotFound - } + id, err := resolveOrg(ctx, tx, ref) + if err != nil { return err } - if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, orgID); err != nil { + ub := sb.PostgreSQL.NewUpdateBuilder() + ub.Update("organizations") + if name != nil { + ub.SetMore(ub.Assign("name", *name)) + } + if slug != nil { + ub.SetMore(ub.Assign("slug", *slug)) + } + if public != nil { + ub.SetMore(ub.Assign("public", *public)) + } + ub.Where(ub.Equal("id", id)) + + sql, args := ub.Build() + if _, err := tx.Exec(ctx, sql, args...); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrSlugTaken + } + return err } return tx.Commit(ctx) } -// RenameOrganization updates the name and/or slug. -func (db *DB) RenameOrganization(ctx context.Context, currentSlug, newName, newSlug string) error { - tag, err := db.Pool.Exec(ctx, - `UPDATE organizations SET name = $1, slug = $2 - WHERE slug = $3 AND deleted_at IS NULL`, - newName, newSlug, currentSlug, - ) +// DeleteOrganization soft-deletes an org and removes all members. +func (db *DB) DeleteOrganization(ctx context.Context, ref OrgRef) error { + tx, err := db.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + id, err := resolveOrg(ctx, tx, ref) if err != nil { - if strings.Contains(err.Error(), "unique") { - return ErrSlugTaken - } return err } - if tag.RowsAffected() == 0 { - return ErrOrgNotFound + + if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, id); err != nil { + return err + } + + if _, err := tx.Exec(ctx, + `UPDATE organizations SET slug = id::text, deleted_at = now() WHERE id = $1`, id, + ); err != nil { + return err } - return nil + + return tx.Commit(ctx) } -// GetOrganizationBySlug returns an org by its slug. -func (db *DB) GetOrganizationBySlug(ctx context.Context, slug string) (*Organization, error) { - var o Organization - err := db.Pool.QueryRow(ctx, - `SELECT id, name, slug, public, created_at FROM organizations WHERE slug = $1 AND deleted_at IS NULL`, - slug, - ).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt) +// ListOrganizations returns a page of orgs and the total count. +func (db *DB) ListOrganizations(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) { + if filter != "" { + return nil, 0, ErrFilterNotImplemented + } + + tx, err := db.Pool.Begin(ctx) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrOrgNotFound - } - return nil, err + return nil, 0, err } - return &o, nil -} + defer tx.Rollback(ctx) -// ListAllOrganizations returns all active orgs. -func (db *DB) ListAllOrganizations(ctx context.Context) ([]Organization, error) { - rows, err := db.Pool.Query(ctx, - `SELECT id, name, slug, public, created_at FROM organizations WHERE deleted_at IS NULL ORDER BY name`, - ) + var total int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM organizations WHERE deleted_at IS NULL`, + ).Scan(&total); err != nil { + return nil, 0, err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "name", "slug", "public", "created_at"). + From("organizations"). + Where(q.IsNull("deleted_at")). + OrderBy("id"). + Limit(limit) + if cursor != uuid.Nil { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() @@ -142,53 +244,84 @@ func (db *DB) ListAllOrganizations(ctx context.Context) ([]Organization, error) for rows.Next() { var o Organization if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { - return nil, err + return nil, 0, err } orgs = append(orgs, o) } - return orgs, rows.Err() + return orgs, total, rows.Err() } -// ListUserOrganizations returns all orgs a user belongs to. -func (db *DB) ListUserOrganizations(ctx context.Context, email string) ([]Organization, error) { - rows, err := db.Pool.Query(ctx, - `SELECT o.id, o.name, o.slug, o.public, o.created_at - FROM organizations o - JOIN org_members m ON m.org_id = o.id - JOIN users u ON u.id = m.user_id - WHERE u.email = $1 AND u.deleted_at IS NULL AND o.deleted_at IS NULL - ORDER BY o.name`, - email, - ) +// GetOrgMember returns a single member's info. +func (db *DB) GetOrgMember(ctx context.Context, org OrgRef, user UserRef) (*OrgMember, error) { + tx, err := db.Pool.Begin(ctx) if err != nil { return nil, err } - defer rows.Close() + defer tx.Rollback(ctx) - var orgs []Organization - for rows.Next() { - var o Organization - if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { - return nil, err + orgID, err := resolveOrg(ctx, tx, org) + if err != nil { + return nil, err + } + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return nil, err + } + + var m OrgMember + if err := tx.QueryRow(ctx, + `SELECT u.id, u.email, u.created_at, om.role, om.created_at + FROM org_members om + JOIN users u ON u.id = om.user_id + WHERE om.org_id = $1 AND om.user_id = $2`, orgID, userID, + ).Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotMember } - orgs = append(orgs, o) + return nil, err } - return orgs, rows.Err() + return &m, nil } -// ListOrgMembers returns all members of an org. -func (db *DB) ListOrgMembers(ctx context.Context, orgSlug string) ([]OrgMember, error) { - rows, err := db.Pool.Query(ctx, - `SELECT u.id, u.email, u.created_at, m.role, m.created_at - FROM org_members m - JOIN users u ON u.id = m.user_id - JOIN organizations o ON o.id = m.org_id - WHERE o.slug = $1 AND o.deleted_at IS NULL AND u.deleted_at IS NULL - ORDER BY m.created_at`, - orgSlug, - ) +// ListOrgMembers returns a page of members for an org. +func (db *DB) ListOrgMembers(ctx context.Context, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) { + if filter != "" { + return nil, 0, ErrFilterNotImplemented + } + + tx, err := db.Pool.Begin(ctx) if err != nil { - return nil, err + return nil, 0, err + } + defer tx.Rollback(ctx) + + orgID, err := resolveOrg(ctx, tx, org) + if err != nil { + return nil, 0, err + } + + var total int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID, + ).Scan(&total); err != nil { + return nil, 0, err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("u.id", "u.email", "u.created_at", "m.role", "m.created_at"). + From("org_members m"). + Join("users u", "u.id = m.user_id"). + Where(q.Equal("m.org_id", orgID), q.IsNull("u.deleted_at")). + OrderBy("u.id"). + Limit(limit) + if cursor != uuid.Nil { + q.Where(q.GreaterThan("u.id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return nil, 0, err } defer rows.Close() @@ -196,61 +329,79 @@ func (db *DB) ListOrgMembers(ctx context.Context, orgSlug string) ([]OrgMember, for rows.Next() { var m OrgMember if err := rows.Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { - return nil, err + return nil, 0, err } + members = append(members, m) } - return members, rows.Err() + + return members, total, rows.Err() } // AddOrgMember adds a user to an org with the given role. -func (db *DB) AddOrgMember(ctx context.Context, orgSlug, email, role string) error { - tag, err := db.Pool.Exec(ctx, - `INSERT INTO org_members (org_id, user_id, role) - SELECT o.id, u.id, $3 - FROM organizations o, users u - WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL AND u.deleted_at IS NULL`, - orgSlug, email, role, - ) +func (db *DB) AddOrgMember(ctx context.Context, org OrgRef, user UserRef, role string) error { + tx, err := db.Pool.Begin(ctx) if err != nil { - if strings.Contains(err.Error(), "duplicate key") { + return err + } + defer tx.Rollback(ctx) + + orgID, err := resolveOrg(ctx, tx, org) + if err != nil { + return err + } + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return err + } + + if _, err := tx.Exec(ctx, + `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3)`, + orgID, userID, role, + ); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { return ErrAlreadyMember } return err } - if tag.RowsAffected() == 0 { - return ErrOrgNotFound - } - return nil + + return tx.Commit(ctx) } -// RemoveOrgMember removes a user from an org. Fails if they are the last owner. -func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error { +// UpdateOrgMemberRole changes a member's role. Fails if demoting the last owner. +func (db *DB) UpdateOrgMemberRole(ctx context.Context, org OrgRef, user UserRef, newRole string) error { tx, err := db.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - var orgID, userID, role string + orgID, err := resolveOrg(ctx, tx, org) + if err != nil { + return err + } + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return err + } + + var currentRole string if err := tx.QueryRow(ctx, - `SELECT o.id, u.id, m.role - FROM org_members m - JOIN organizations o ON o.id = m.org_id - JOIN users u ON u.id = m.user_id - WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL`, - orgSlug, email, - ).Scan(&orgID, &userID, &role); err != nil { + `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, + orgID, userID, + ).Scan(¤tRole); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotMember } return err } - if role == "owner" { + if currentRole == "owner" && newRole != "owner" { var ownerCount int if err := tx.QueryRow(ctx, - `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, orgID, + `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, + orgID, ).Scan(&ownerCount); err != nil { return err } @@ -260,7 +411,8 @@ func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error } if _, err := tx.Exec(ctx, - `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, orgID, userID, + `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`, + newRole, orgID, userID, ); err != nil { return err } @@ -268,33 +420,39 @@ func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error return tx.Commit(ctx) } -// ChangeOrgMemberRole changes a member's role. Fails if demoting the last owner. -func (db *DB) ChangeOrgMemberRole(ctx context.Context, orgSlug, email, newRole string) error { +// RemoveOrgMember removes a user from an org. Fails if they are the last owner. +func (db *DB) RemoveOrgMember(ctx context.Context, org OrgRef, user UserRef) error { tx, err := db.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - var orgID, userID, currentRole string + orgID, err := resolveOrg(ctx, tx, org) + if err != nil { + return err + } + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return err + } + + var role string if err := tx.QueryRow(ctx, - `SELECT o.id, u.id, m.role - FROM org_members m - JOIN organizations o ON o.id = m.org_id - JOIN users u ON u.id = m.user_id - WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL`, - orgSlug, email, - ).Scan(&orgID, &userID, ¤tRole); err != nil { + `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, + orgID, userID, + ).Scan(&role); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotMember } return err } - if currentRole == "owner" && newRole != "owner" { + if role == "owner" { var ownerCount int if err := tx.QueryRow(ctx, - `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, orgID, + `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, + orgID, ).Scan(&ownerCount); err != nil { return err } @@ -304,8 +462,8 @@ func (db *DB) ChangeOrgMemberRole(ctx context.Context, orgSlug, email, newRole s } if _, err := tx.Exec(ctx, - `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`, - newRole, orgID, userID, + `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, + orgID, userID, ); err != nil { return err } diff --git a/internal/database/user.go b/internal/database/user.go index 6cf5f6f..78dae2b 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -15,19 +15,37 @@ import ( "strings" "time" + "github.com/google/uuid" + sb "github.com/huandu/go-sqlbuilder" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/argon2" ) var ( - ErrCreateUser = errors.New("database: failed to create user") - ErrSetPassword = errors.New("database: failed to set password") - ErrDeleteUser = errors.New("database: failed to delete user") - ErrInvalidCreds = errors.New("invalid credentials") - ErrSoleOwner = errors.New("database: user is the sole owner of an organization") + ErrSoleOwner = errors.New("database: sole owner of an organization") + ErrEmailTaken = errors.New("database: email already taken") + ErrInvalidCreds = errors.New("database: invalid credentials") ErrUserNotFound = errors.New("database: user not found") ) +// UserRef identifies a user by ID or email. +type UserRef struct { + id uuid.UUID + email string +} + +func UserByID(id uuid.UUID) UserRef { return UserRef{id: id} } +func UserByEmail(email string) UserRef { return UserRef{email: email} } + +func (r UserRef) where() (string, any) { + if r.id != uuid.Nil { + return "id", r.id + } + return "email", r.email +} + const ( saltLen = 16 @@ -41,14 +59,14 @@ const ( // User holds info about a user. type User struct { - ID string + ID uuid.UUID Email string CreatedAt time.Time } // Session holds info about an authenticated session. type Session struct { - UserID string + UserID uuid.UUID Email string } @@ -116,159 +134,289 @@ func hashPassword(password string, pepper []byte) (string, error) { // UserCreate hashes the password with argon2id and inserts a new user. // The pepper is a server-side secret not stored in the database. -func (db *DB) UserCreate(ctx context.Context, email, password string, pepper []byte) (string, error) { +func (db *DB) UserCreate(ctx context.Context, email, password string, pepper []byte) (uuid.UUID, error) { hash, err := hashPassword(password, pepper) if err != nil { - return "", err + return uuid.Nil, err } - var id string - err = db.Pool.QueryRow(ctx, + var id uuid.UUID + if err := db.Pool.QueryRow(ctx, `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id`, email, hash, - ).Scan(&id) - if err != nil { - return "", errors.Join(ErrCreateUser, err) + ).Scan(&id); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return uuid.Nil, ErrEmailTaken + } + return uuid.Nil, err } return id, nil } -// UserDelete clears all fields but keeps the row to preserve the id. -// Also deletes all sessions for that user in a single transaction. -func (db *DB) UserDelete(ctx context.Context, email string) error { +// GetUser returns a user by ref (ID or email). +func (db *DB) GetUser(ctx context.Context, ref UserRef) (*User, error) { + col, val := ref.where() + + q := sb.PostgreSQL.NewSelectBuilder() + sql, args := q.Select("id", "email", "created_at"). + From("users"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + Build() + + var u User + if err := db.Pool.QueryRow(ctx, sql, args...).Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + + return nil, err + } + + return &u, nil +} + +// ListUsers returns a page of users and the total count. +func (db *DB) ListUsers(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]User, int, error) { + if filter != "" { + return nil, 0, ErrFilterNotImplemented + } + tx, err := db.Pool.Begin(ctx) if err != nil { - return errors.Join(ErrDeleteUser, err) + return nil, 0, err } defer tx.Rollback(ctx) - var id string - err = tx.QueryRow(ctx, - `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE email = $1 RETURNING id`, - email, - ).Scan(&id) + var total int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM users WHERE deleted_at IS NULL`, + ).Scan(&total); err != nil { + return nil, 0, err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "email", "created_at"). + From("users"). + Where(q.IsNull("deleted_at")). + OrderBy("id"). + Limit(limit) + if cursor != uuid.Nil { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) if err != nil { - return errors.Join(ErrDeleteUser, err) + return nil, 0, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + return nil, 0, err + } + users = append(users, u) } - var soloOwnedSlug string - err = tx.QueryRow(ctx, + return users, total, rows.Err() +} + +// checkNotSoleOwner returns ErrSoleOwner if the user is the only owner of any org. +func checkNotSoleOwner(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error { + var slug string + err := tx.QueryRow(ctx, `SELECT o.slug FROM org_members m JOIN organizations o ON o.id = m.org_id WHERE m.role = 'owner' AND o.deleted_at IS NULL GROUP BY o.id, o.slug HAVING count(*) = 1 AND bool_or(m.user_id = $1) - LIMIT 1`, id, - ).Scan(&soloOwnedSlug) + LIMIT 1`, userID, + ).Scan(&slug) if err == nil { return ErrSoleOwner } - if !errors.Is(err, pgx.ErrNoRows) { - return errors.Join(ErrDeleteUser, err) + if errors.Is(err, pgx.ErrNoRows) { + return nil } + return err +} - if _, err = tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil { - return errors.Join(ErrDeleteUser, err) - } +// resolveUser locks and returns the user ID within a transaction. +func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (uuid.UUID, error) { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() - if _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { - return errors.Join(ErrDeleteUser, err) + sql, args := q.Select("id").From("users"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + ForUpdate(). + Build() + + var id uuid.UUID + if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, ErrUserNotFound + } + + return uuid.Nil, err } - return tx.Commit(ctx) + return id, nil } -// UserSetPassword updates the password and invalidates all existing sessions. -func (db *DB) UserSetPassword(ctx context.Context, email, password string, pepper []byte) error { - hash, err := hashPassword(password, pepper) +// UserUpdate updates a user's email and/or password. +// Invalidates all sessions when password changes. +func (db *DB) UserUpdate(ctx context.Context, ref UserRef, email *string, password *string, pepper []byte) error { + if email == nil && password == nil { + return nil + } + + tx, err := db.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + id, err := resolveUser(ctx, tx, ref) if err != nil { return err } + ub := sb.PostgreSQL.NewUpdateBuilder() + ub.Update("users") + + if email != nil { + ub.SetMore(ub.Assign("email", *email)) + } + + if password != nil { + hash, err := hashPassword(*password, pepper) + if err != nil { + return err + } + + ub.SetMore(ub.Assign("password", hash)) + } + + ub.Where(ub.Equal("id", id)) + + sql, args := ub.Build() + if _, err := tx.Exec(ctx, sql, args...); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrEmailTaken + } + return err + } + + if password != nil { + if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +// UserDelete soft-deletes a user. +// Fails if the user is the sole owner of any organization. +func (db *DB) UserDelete(ctx context.Context, ref UserRef) error { tx, err := db.Pool.Begin(ctx) if err != nil { - return errors.Join(ErrSetPassword, err) + return err } defer tx.Rollback(ctx) - var id string - err = tx.QueryRow(ctx, - `UPDATE users SET password = $1 WHERE email = $2 AND deleted_at IS NULL RETURNING id`, - hash, email, - ).Scan(&id) + id, err := resolveUser(ctx, tx, ref) if err != nil { - return errors.Join(ErrSetPassword, err) + return err } - _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id) - if err != nil { - return errors.Join(ErrSetPassword, err) + if err := checkNotSoleOwner(ctx, tx, id); err != nil { + return err + } + + if _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { + return err + } + + if _, err = tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil { + return err + } + + if _, err := tx.Exec(ctx, + `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE id = $1`, id, + ); err != nil { + return err } return tx.Commit(ctx) } // UserVerifyPassword checks credentials and returns the user ID. -func (db *DB) UserVerifyPassword(ctx context.Context, email, password string, pepper []byte) (string, error) { - var id, hash string - err := db.Pool.QueryRow(ctx, +func (db *DB) UserVerifyPassword(ctx context.Context, email, password string, pepper []byte) (uuid.UUID, error) { + var id uuid.UUID + var hash string + + if err := db.Pool.QueryRow(ctx, `SELECT id, password FROM users WHERE email = $1 AND deleted_at IS NULL`, email, - ).Scan(&id, &hash) - if err != nil { - return "", ErrInvalidCreds + ).Scan(&id, &hash); err != nil { + return uuid.Nil, ErrInvalidCreds } if !verifyHash(password, hash, pepper) { - return "", ErrInvalidCreds + return uuid.Nil, ErrInvalidCreds } return id, nil } // UserGetSession returns session info for a valid, non-expired session. -// It extends the session expiry only when less than half the TTL remains, -// avoiding a write on every request. func (db *DB) UserGetSession(ctx context.Context, token string) (*Session, error) { - h := hashToken(token) var s Session + h := hashToken(token) var expiresAt time.Time - err := db.Pool.QueryRow(ctx, + + if err := db.Pool.QueryRow(ctx, `SELECT s.user_id, u.email, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, h, - ).Scan(&s.UserID, &s.Email, &expiresAt) - if err != nil { + ).Scan(&s.UserID, &s.Email, &expiresAt); err != nil { return nil, err } if time.Until(expiresAt) < SessionTTL/2 { - db.Pool.Exec(ctx, + if _, err := db.Pool.Exec(ctx, `UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`, h, SessionTTL, - ) + ); err != nil { + return nil, err + } } return &s, nil } // UserCreateSession generates a random token, stores its hash, and returns the token. -func (db *DB) UserCreateSession(ctx context.Context, userID string) (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { +func (db *DB) UserCreateSession(ctx context.Context, userID uuid.UUID) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { return "", err } - token := base64.RawURLEncoding.EncodeToString(b) + token := base64.RawURLEncoding.EncodeToString(buf) - _, err := db.Pool.Exec(ctx, + if _, err := db.Pool.Exec(ctx, `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`, hashToken(token), userID, SessionTTL, - ) - if err != nil { + ); err != nil { return "", err } + return token, nil } diff --git a/internal/database/validate.go b/internal/database/validate.go new file mode 100644 index 0000000..9dc2b44 --- /dev/null +++ b/internal/database/validate.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package database + +import ( + "errors" + "net/mail" + "regexp" + "strings" +) + +var slugRe = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`) + +var validRoles = map[string]bool{ + "owner": true, + "admin": true, + "member": true, +} + +var ( + ErrInvalidEmail = errors.New("database: invalid email") + ErrInvalidSlug = errors.New("database: invalid slug") + ErrInvalidRole = errors.New("database: invalid role") +) + +// ValidateEmail checks that the value is a valid email address. +func ValidateEmail(value string) error { + if _, err := mail.ParseAddress(value); err != nil { + return ErrInvalidEmail + } + return nil +} + +// ValidateSlug checks format and returns the normalized (lowercased) slug. +func ValidateSlug(value string) (string, error) { + if len(value) < 2 || len(value) > 64 || !slugRe.MatchString(value) { + return "", ErrInvalidSlug + } + return strings.ToLower(value), nil +} + +// ValidateRole checks that the value is a valid role string. +func ValidateRole(value string) error { + if !validRoles[value] { + return ErrInvalidRole + } + return nil +} + +// ClampPageSize clamps a page_size to [1, max]. If v is 0 (unset), returns defaultSize. +func ClampPageSize(v int32, defaultSize, max int32) int32 { + if v <= 0 { + return defaultSize + } + if v > max { + return max + } + return v +} diff --git a/internal/database/worker.go b/internal/database/worker.go index bdd4b0b..c0ff46b 100644 --- a/internal/database/worker.go +++ b/internal/database/worker.go @@ -8,52 +8,138 @@ import ( "errors" "time" + "github.com/google/uuid" + sb "github.com/huandu/go-sqlbuilder" "github.com/jackc/pgx/v5" ) var ( - ErrAddWorker = errors.New("database: failed to add worker") - ErrRevokeWorker = errors.New("database: failed to revoke worker") ErrWorkerNotFound = errors.New("database: worker not found") ) // Worker holds info about a registered worker. type Worker struct { - ID string + ID uuid.UUID + OrgID *uuid.UUID PublicKey []byte CreatedAt time.Time } -// ListWorkers returns all active (non-revoked) workers. -func (db *DB) ListWorkers(ctx context.Context) ([]Worker, error) { - rows, err := db.Pool.Query(ctx, - `SELECT id, public_key, created_at FROM workers WHERE revoked_at IS NULL ORDER BY created_at`, +// GetWorker returns a worker by ID. +func (db *DB) GetWorker(ctx context.Context, id uuid.UUID) (*Worker, error) { + var w Worker + if err := db.Pool.QueryRow(ctx, + `SELECT id, public_key, org_id, created_at FROM workers WHERE id = $1 AND revoked_at IS NULL`, + id, + ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrWorkerNotFound + } + + return nil, err + } + + return &w, nil +} + +// CreateWorker registers a new worker with the given public key and optional org. +func (db *DB) CreateWorker(ctx context.Context, publicKey []byte, org *OrgRef) (uuid.UUID, error) { + tx, err := db.Pool.Begin(ctx) + if err != nil { + return uuid.Nil, err + } + defer tx.Rollback(ctx) + + var orgID *uuid.UUID + if org != nil { + id, err := resolveOrg(ctx, tx, *org) + if err != nil { + return uuid.Nil, err + } + orgID = &id + } + + var workerID uuid.UUID + if err := tx.QueryRow(ctx, + `INSERT INTO workers (public_key, org_id) VALUES ($1, $2) RETURNING id`, + publicKey, orgID, + ).Scan(&workerID); err != nil { + return uuid.Nil, err + } + + return workerID, tx.Commit(ctx) +} + +// DeleteWorker soft-deletes a worker by ID. +func (db *DB) DeleteWorker(ctx context.Context, id uuid.UUID) error { + tag, err := db.Pool.Exec(ctx, + `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id, ) if err != nil { - return nil, err + return err + } + + if tag.RowsAffected() == 0 { + return ErrWorkerNotFound + } + + return nil +} + +// ListWorkers returns a page of workers and the total count. +func (db *DB) ListWorkers(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) { + if filter != "" { + return nil, 0, ErrFilterNotImplemented + } + + tx, err := db.Pool.Begin(ctx) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) + + var total int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM workers WHERE revoked_at IS NULL`, + ).Scan(&total); err != nil { + return nil, 0, err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "public_key", "org_id", "created_at"). + From("workers"). + Where(q.IsNull("revoked_at")). + OrderBy("id"). + Limit(limit) + if cursor != uuid.Nil { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return nil, 0, err } defer rows.Close() var workers []Worker for rows.Next() { var w Worker - if err := rows.Scan(&w.ID, &w.PublicKey, &w.CreatedAt); err != nil { - return nil, err + if err := rows.Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { + return nil, 0, err } workers = append(workers, w) } - - return workers, rows.Err() + return workers, total, rows.Err() } // LookupWorker finds an active worker by its ed25519 public key. func (db *DB) LookupWorker(ctx context.Context, publicKey []byte) (*Worker, error) { var w Worker - err := db.Pool.QueryRow(ctx, - `SELECT id, public_key, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`, + if err := db.Pool.QueryRow(ctx, + `SELECT id, public_key, org_id, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`, publicKey, - ).Scan(&w.ID, &w.PublicKey, &w.CreatedAt) - if err != nil { + ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrWorkerNotFound } @@ -61,31 +147,3 @@ func (db *DB) LookupWorker(ctx context.Context, publicKey []byte) (*Worker, erro } return &w, nil } - -// AddWorker registers a new worker with the given public key. -func (db *DB) AddWorker(ctx context.Context, publicKey []byte) (string, error) { - var id string - err := db.Pool.QueryRow(ctx, - `INSERT INTO workers (public_key) VALUES ($1) RETURNING id`, - publicKey, - ).Scan(&id) - if err != nil { - return "", errors.Join(ErrAddWorker, err) - } - return id, nil -} - -// WorkerRevoke soft-deletes a worker by ID. -func (db *DB) WorkerRevoke(ctx context.Context, id string) error { - tag, err := db.Pool.Exec(ctx, - `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, - id, - ) - if err != nil { - return errors.Join(ErrRevokeWorker, err) - } - if tag.RowsAffected() == 0 { - return ErrWorkerNotFound - } - return nil -} diff --git a/proto/admin.proto b/proto/admin.proto index a8f4b74..e3edb21 100644 --- a/proto/admin.proto +++ b/proto/admin.proto @@ -5,157 +5,273 @@ syntax = "proto3"; package mirum; -option go_package = "dimidiumlabs/mirum/internal/protocol/pb"; - +import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; +option go_package = "dimidiumlabs/mirum/internal/protocol/pb"; + service Admin { rpc UserCreate(UserCreateRequest) returns (UserCreateResponse); + rpc UserGet(UserGetRequest) returns (UserGetResponse); + rpc UserList(UserListRequest) returns (UserListResponse); + rpc UserUpdate(UserUpdateRequest) returns (UserUpdateResponse); rpc UserDelete(UserDeleteRequest) returns (UserDeleteResponse); - rpc UserSetPassword(UserSetPasswordRequest) returns (UserSetPasswordResponse); - rpc OrgList(OrgListRequest) returns (OrgListResponse); rpc OrgCreate(OrgCreateRequest) returns (OrgCreateResponse); + rpc OrgGet(OrgGetRequest) returns (OrgGetResponse); + rpc OrgList(OrgListRequest) returns (OrgListResponse); + rpc OrgUpdate(OrgUpdateRequest) returns (OrgUpdateResponse); rpc OrgDelete(OrgDeleteRequest) returns (OrgDeleteResponse); - rpc OrgRename(OrgRenameRequest) returns (OrgRenameResponse); - rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse); rpc OrgMemberAdd(OrgMemberAddRequest) returns (OrgMemberAddResponse); + rpc OrgMemberGet(OrgMemberGetRequest) returns (OrgMemberGetResponse); + rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse); + rpc OrgMemberUpdate(OrgMemberUpdateRequest) returns (OrgMemberUpdateResponse); rpc OrgMemberRemove(OrgMemberRemoveRequest) returns (OrgMemberRemoveResponse); - rpc OrgMemberSetRole(OrgMemberSetRoleRequest) returns (OrgMemberSetRoleResponse); + rpc WorkerCreate(WorkerCreateRequest) returns (WorkerCreateResponse); + rpc WorkerGet(WorkerGetRequest) returns (WorkerGetResponse); rpc WorkerList(WorkerListRequest) returns (WorkerListResponse); - rpc WorkerAdd(WorkerAddRequest) returns (WorkerAddResponse); - rpc WorkerRevoke(WorkerRevokeRequest) returns (WorkerRevokeResponse); + rpc WorkerDelete(WorkerDeleteRequest) returns (WorkerDeleteResponse); } -message UserCreateRequest { - string email = 1; - string password = 2; -} +// Common types -message UserCreateResponse { - string id = 1; +enum Role { + ROLE_NONE = 0; + ROLE_OWNER = 1; + ROLE_ADMIN = 2; + ROLE_MEMBER = 3; } -message UserSetPasswordRequest { - string email = 1; - string password = 2; +message UserRef { + oneof ref { + option (buf.validate.oneof).required = true; + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; + string email = 2 [(buf.validate.field).string.email = true]; + } } -message UserSetPasswordResponse {} - -message UserDeleteRequest { - string email = 1; +message OrgRef { + oneof ref { + option (buf.validate.oneof).required = true; + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; + string slug = 2 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + } } -message UserDeleteResponse {} +message PageRequest { + bytes cursor = 1; + int32 page_size = 2; +} +message PageResponse { + bytes next_cursor = 1; + int32 total_count = 2; +} -// Organization management +// User management -message OrgCreateRequest { - string name = 1; - string slug = 2; - bool public = 3; - string owner_email = 4; +message User { + bytes id = 1; + string email = 2; + google.protobuf.Timestamp created_at = 3; } -message OrgCreateResponse { - string id = 1; +message UserCreateRequest { + string email = 1 [(buf.validate.field).string.email = true]; + string password = 2 [(buf.validate.field).string.min_len = 1]; } - -message OrgDeleteRequest { - string slug = 1; +message UserCreateResponse { + bytes id = 1; } -message OrgDeleteResponse {} - -message OrgRenameRequest { - string slug = 1; - string new_name = 2; - string new_slug = 3; +message UserGetRequest { + UserRef user = 1 [(buf.validate.field).required = true]; +} +message UserGetResponse { + User user = 1; } -message OrgRenameResponse {} +message UserListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} +message UserListResponse { + PageResponse page = 1; + repeated User users = 2; +} -message OrgListRequest { - optional string user_email = 1; +message UserUpdateRequest { + UserRef user = 1 [(buf.validate.field).required = true]; + optional string email = 2 [(buf.validate.field).string.email = true]; + optional string password = 3 [(buf.validate.field).string.min_len = 1]; } +message UserUpdateResponse {} -message OrgListResponse { - repeated Org organizations = 1; +message UserDeleteRequest { + UserRef user = 1 [(buf.validate.field).required = true]; } +message UserDeleteResponse {} + +// Organization management message Org { - string id = 1; + bytes id = 1; string name = 2; string slug = 3; bool public = 4; google.protobuf.Timestamp created_at = 5; } -// Organization members +message OrgCreateRequest { + string name = 1 [(buf.validate.field).string.min_len = 1]; + string slug = 2 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + UserRef owner = 3 [(buf.validate.field).required = true]; + bool public = 4; +} +message OrgCreateResponse { + bytes id = 1; +} -message OrgMemberAddRequest { - string org_slug = 1; - string email = 2; - string role = 3; +message OrgGetRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; +} +message OrgGetResponse { + Org org = 1; } -message OrgMemberAddResponse {} +message OrgListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} +message OrgListResponse { + PageResponse page = 1; + repeated Org organizations = 2; +} -message OrgMemberRemoveRequest { - string org_slug = 1; - string email = 2; +message OrgUpdateRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + optional string name = 2 [(buf.validate.field).string.min_len = 1]; + optional string slug = 3 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + optional bool public = 4; } +message OrgUpdateResponse {} -message OrgMemberRemoveResponse {} +message OrgDeleteRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; +} +message OrgDeleteResponse {} -message OrgMemberSetRoleRequest { - string org_slug = 1; - string email = 2; - string role = 3; +// Organization members + +message OrgMemberInfo { + User user = 1; + Role role = 2; + google.protobuf.Timestamp joined_at = 3; +} + +message OrgMemberGetRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; +} +message OrgMemberGetResponse { + OrgMemberInfo member = 1; } -message OrgMemberSetRoleResponse {} +message OrgMemberAddRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; + Role role = 3 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; +} +message OrgMemberAddResponse {} message OrgMemberListRequest { - string org_slug = 1; + optional PageRequest page = 1; + OrgRef org = 2 [(buf.validate.field).required = true]; + optional string filter = 3; } - message OrgMemberListResponse { - repeated OrgMemberInfo members = 1; + PageResponse page = 1; + repeated OrgMemberInfo members = 2; } -message OrgMemberInfo { - string email = 1; - string role = 2; - google.protobuf.Timestamp joined_at = 3; +message OrgMemberUpdateRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; + Role role = 3 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; } +message OrgMemberUpdateResponse {} -message WorkerAddRequest { - bytes public_key = 1; +message OrgMemberRemoveRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; } +message OrgMemberRemoveResponse {} -message WorkerAddResponse { - string id = 1; -} +// Worker management -message WorkerRevokeRequest { - string id = 1; +message Worker { + bytes id = 1; + bytes public_key = 2; + optional bytes org_id = 3; + google.protobuf.Timestamp created_at = 4; } -message WorkerRevokeResponse {} +message WorkerCreateRequest { + bytes public_key = 1 [(buf.validate.field).bytes = { + min_len: 32 + max_len: 32 + }]; + optional OrgRef org = 2; +} +message WorkerCreateResponse { + bytes id = 1; +} -message WorkerListRequest {} +message WorkerGetRequest { + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; +} +message WorkerGetResponse { + Worker worker = 1; +} +message WorkerListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} message WorkerListResponse { - repeated Worker workers = 1; + PageResponse page = 1; + repeated Worker workers = 2; } -message Worker { - string id = 1; - bytes public_key = 2; - google.protobuf.Timestamp created_at = 3; +message WorkerDeleteRequest { + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; } - +message WorkerDeleteResponse {} diff --git a/proto/buf.lock b/proto/buf.lock new file mode 100644 index 0000000..d15a117 --- /dev/null +++ b/proto/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 80ab13bee0bf4272b6161a72bf7034e0 + digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..8d594f7 --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,6 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +version: v2 +deps: + - buf.build/bufbuild/protovalidate -- Gilti