Files
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

287 lines
8.8 KiB
Go

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")
}