package openapi import ( "context" "errors" "strings" "time" "github.com/danielgtaylor/huma/v2" "anchorage/internal/pkg/auth" "anchorage/internal/pkg/ids" "anchorage/internal/pkg/store" ) // RegisterOrgs wires org + membership endpoints on api. // // Authz model: // - `POST /v1/orgs` — any authenticated user. The creator is auto-added // as the first orgadmin. // - `GET /v1/orgs` — lists the caller's memberships (not every org // that exists — that would be a cross-tenant leak). // - `GET /v1/orgs/{id}` / `PATCH /v1/orgs/{id}` — orgadmin of that // org, or sysadmin. // - `POST/DELETE /v1/orgs/{id}/members` — orgadmin of that org, or // sysadmin. func RegisterOrgs(api huma.API, s store.Store) { huma.Register(api, huma.Operation{ OperationID: "createOrg", Method: "POST", Path: "/orgs", Summary: "Create a new organisation", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, in *struct { Body struct { Slug string `json:"slug" minLength:"2" maxLength:"64" pattern:"^[a-z0-9][a-z0-9-]*$"` Name string `json:"name" minLength:"1" maxLength:"256"` } }) (*struct{ Body orgBody }, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } orgID, err := ids.NewOrg() if err != nil { return nil, huma.Error500InternalServerError("id", err) } org, err := s.Orgs().Create(ctx, orgID, strings.ToLower(in.Body.Slug), in.Body.Name) if err != nil { if errors.Is(err, store.ErrConflict) { return nil, huma.Error409Conflict("slug already in use") } return nil, huma.Error500InternalServerError("create org", err) } if err := s.Memberships().Add(ctx, org.ID, cc.User, store.RoleOrgAdmin); err != nil { return nil, huma.Error500InternalServerError("add membership", err) } _ = s.Audit().Insert(ctx, &store.AuditEntry{ OrgID: &org.ID, ActorUserID: &cc.User, Action: "org.create", Target: org.ID.String(), Result: "ok", Detail: map[string]any{"slug": org.Slug, "name": org.Name}, }) return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil }) huma.Register(api, huma.Operation{ OperationID: "listMyOrgs", Method: "GET", Path: "/orgs", Summary: "List organisations the caller is a member of", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, _ *struct{}) (*struct { Body struct { Results []orgBody `json:"results"` } }, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } memberships, err := s.Memberships().ListForUser(ctx, cc.User) if err != nil { return nil, huma.Error500InternalServerError("list", err) } out := &struct { Body struct { Results []orgBody `json:"results"` } }{} for _, m := range memberships { out.Body.Results = append(out.Body.Results, orgBody{ ID: m.OrgID.String(), Slug: m.OrgSlug, Name: m.OrgName, Role: m.Role, }) } return out, nil }) huma.Register(api, huma.Operation{ OperationID: "getOrg", Method: "GET", Path: "/orgs/{org_id}", Summary: "Fetch an organisation by ID", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, in *struct { OrgID string `path:"org_id"` }) (*struct{ Body orgBody }, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } orgID, err := ids.ParseOrg(in.OrgID) if err != nil { return nil, huma.Error400BadRequest("bad org id") } if err := requireOrgMember(ctx, s, cc, orgID); err != nil { return nil, err } org, err := s.Orgs().GetByID(ctx, orgID) if err != nil { return nil, huma.Error404NotFound("org") } return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil }) huma.Register(api, huma.Operation{ OperationID: "patchOrg", Method: "PATCH", Path: "/orgs/{org_id}", Summary: "Rename an organisation", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, in *struct { OrgID string `path:"org_id"` Body struct { Name string `json:"name" minLength:"1" maxLength:"256"` } }) (*struct{ Body orgBody }, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } orgID, err := ids.ParseOrg(in.OrgID) if err != nil { return nil, huma.Error400BadRequest("bad org id") } if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { return nil, err } org, err := s.Orgs().UpdateName(ctx, orgID, in.Body.Name) if err != nil { if errors.Is(err, store.ErrNotFound) { return nil, huma.Error404NotFound("org") } return nil, huma.Error500InternalServerError("update", err) } _ = s.Audit().Insert(ctx, &store.AuditEntry{ OrgID: &orgID, ActorUserID: &cc.User, Action: "org.rename", Target: orgID.String(), Result: "ok", Detail: map[string]any{"name": in.Body.Name}, }) return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil }) huma.Register(api, huma.Operation{ OperationID: "addMember", Method: "POST", Path: "/orgs/{org_id}/members", Summary: "Add a user to an organisation", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, in *struct { OrgID string `path:"org_id"` Body struct { UserID string `json:"user_id"` Role string `json:"role" enum:"orgadmin,member" default:"member"` } }) (*struct{}, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } orgID, err := ids.ParseOrg(in.OrgID) if err != nil { return nil, huma.Error400BadRequest("bad org id") } if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { return nil, err } userID, err := ids.ParseUser(in.Body.UserID) if err != nil { return nil, huma.Error400BadRequest("bad user id") } if err := s.Memberships().Add(ctx, orgID, userID, in.Body.Role); err != nil { return nil, huma.Error500InternalServerError("add", err) } _ = s.Audit().Insert(ctx, &store.AuditEntry{ OrgID: &orgID, ActorUserID: &cc.User, Action: "org.member.add", Target: userID.String(), Result: "ok", Detail: map[string]any{"role": in.Body.Role}, }) return &struct{}{}, nil }) huma.Register(api, huma.Operation{ OperationID: "removeMember", Method: "DELETE", Path: "/orgs/{org_id}/members/{user_id}", Summary: "Remove a user from an organisation", Security: []map[string][]string{{"accessToken": {}}}, }, func(ctx context.Context, in *struct { OrgID string `path:"org_id"` UserID string `path:"user_id"` }) (*struct{}, error) { cc, err := auth.Require(ctx) if err != nil { return nil, huma.Error401Unauthorized("auth") } orgID, err := ids.ParseOrg(in.OrgID) if err != nil { return nil, huma.Error400BadRequest("bad org id") } if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { return nil, err } userID, err := ids.ParseUser(in.UserID) if err != nil { return nil, huma.Error400BadRequest("bad user id") } if err := s.Memberships().Remove(ctx, orgID, userID); err != nil { return nil, huma.Error500InternalServerError("remove", err) } _ = s.Audit().Insert(ctx, &store.AuditEntry{ OrgID: &orgID, ActorUserID: &cc.User, Action: "org.member.remove", Target: userID.String(), Result: "ok", }) return &struct{}{}, nil }) } type orgBody struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Role string `json:"role,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` } func toOrgBody(o *store.Org) orgBody { return orgBody{ ID: o.ID.String(), Slug: o.Slug, Name: o.Name, CreatedAt: o.CreatedAt, } } // requireOrgMember fails with 403 unless cc is a member of orgID (or // cc is a sysadmin — cross-org read is one of the sysadmin privileges). func requireOrgMember(ctx context.Context, s store.Store, cc *auth.ClientContext, orgID ids.OrgID) error { if cc.Role == "sysadmin" { return nil } memberships, err := s.Memberships().ListForUser(ctx, cc.User) if err != nil { return huma.Error500InternalServerError("lookup membership", err) } for _, m := range memberships { if m.OrgID == orgID { return nil } } return huma.Error403Forbidden("not a member of this org") } // requireOrgAdmin tightens requireOrgMember: must be role=orgadmin in // that specific org (sysadmins are always allowed). func requireOrgAdmin(ctx context.Context, s store.Store, cc *auth.ClientContext, orgID ids.OrgID) error { if cc.Role == "sysadmin" { return nil } memberships, err := s.Memberships().ListForUser(ctx, cc.User) if err != nil { return huma.Error500InternalServerError("lookup membership", err) } for _, m := range memberships { if m.OrgID == orgID && m.Role == store.RoleOrgAdmin { return nil } } return huma.Error403Forbidden("orgadmin required") }