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.
287 lines
8.8 KiB
Go
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")
|
|
}
|