From b3d7906f326d1186ae0b0a2aaab89944354590a2 Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 20 Oct 2026 23:48:16 +0300 Subject: [PATCH] spindle: take admins, admission and invite codes the way knot2 does Signed-off-by: dawn --- docker-compose.yml | 2 + .../content/docs/reference/spindle-config.md | 4 +- .../content/docs/spindles/architecture.mdx | 6 +- .../src/content/docs/spindles/self-hosting.md | 71 +++-- .../tangled/temp/spindle/allowList/add.json | 2 +- .../tangled/temp/spindle/allowList/list.json | 2 +- .../temp/spindle/allowList/remove.json | 2 +- localinfra/pc/services/spindle/run | 2 + nix/modules/spindle.nix | 15 + nix/vm.nix | 2 + spindle/allowlist_test.go | 51 +++- spindle/config/config.go | 72 +++-- spindle/config/config_test.go | 79 ++++++ spindle/db/allowlist.go | 11 +- spindle/db/db.go | 13 + spindle/db/invites.go | 62 ++++ spindle/fakes_test.go | 1 + spindle/ingester.go | 2 +- spindle/ingester_test.go | 18 ++ spindle/repos.go | 4 +- spindle/repos_test.go | 45 +++ spindle/server.go | 15 +- spindle/wipe.go | 6 +- spindle/xrpc/allowlist.go | 18 +- spindle/xrpc/allowlist_test.go | 2 +- spindle/xrpc/ban.go | 8 +- spindle/xrpc/ban_test.go | 2 +- spindle/xrpc/invites.go | 87 ++++++ spindle/xrpc/invites_test.go | 267 ++++++++++++++++++ spindle/xrpc/quota.go | 21 +- spindle/xrpc/quota_test.go | 2 +- spindle/xrpc/xrpc.go | 4 + 32 files changed, 797 insertions(+), 101 deletions(-) create mode 100644 spindle/db/invites.go create mode 100644 spindle/xrpc/invites.go create mode 100644 spindle/xrpc/invites_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 8d870e5bb..091c49c76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -292,6 +292,8 @@ services: IDRESOLVER_DEV: "1" SPINDLE_SERVER_HOSTNAME: spindle.tngl.boltless.dev SPINDLE_SERVER_OWNER: did:plc:igzuueb63dytyy3tpaweofuu + # e2e runs arbitrary test accounts on this spindle + SPINDLE_SERVER_ADMISSION: open SPINDLE_SERVER_KEY_PATH: /var/lib/spindle/key.pem SPINDLE_SERVER_LISTEN_ADDR: 0.0.0.0:6555 SPINDLE_SERVER_METRICS_LISTEN_ADDR: 0.0.0.0:9091 diff --git a/docs/src/content/docs/reference/spindle-config.md b/docs/src/content/docs/reference/spindle-config.md index 2a1db132b..08134dbc2 100644 --- a/docs/src/content/docs/reference/spindle-config.md +++ b/docs/src/content/docs/reference/spindle-config.md @@ -47,7 +47,9 @@ roles. | Variable | Default | Description | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `SPINDLE_SERVER_HOSTNAME` | required | Public hostname. Repos select this spindle by hostname. | -| `SPINDLE_SERVER_OWNER` | required (standalone, mill) | DID of the spindle owner. The owner manages members, quotas and bans. | +| `SPINDLE_SERVER_OWNER` | required (standalone, mill) | DID of the spindle owner, always an admin. | +| `SPINDLE_SERVER_ADMINS` | unset | Comma-separated DIDs of more admins. Admins manage the allow-list, bans, quotas and invite codes. | +| `SPINDLE_SERVER_ADMISSION` | `closed` | `closed` serves admins and the allow-list only. `open` serves anyone who isn't banned. Anything else fails startup. | | `SPINDLE_SERVER_KEY_PATH` | required (standalone, mill) | PEM file with the EC P-256 private key behind `/.well-known/did.json`. Spindle never generates it. | | `SPINDLE_SERVER_LISTEN_ADDR` | `0.0.0.0:6555` | HTTP listener for XRPC, `/_health`, `/mill`, `/.well-known/did.json` and OIDC discovery. | | `SPINDLE_SERVER_METRICS_LISTEN_ADDR` | `127.0.0.1:9091` | Prometheus metrics listener. | diff --git a/docs/src/content/docs/spindles/architecture.mdx b/docs/src/content/docs/spindles/architecture.mdx index 1a79175d3..a614a543b 100644 --- a/docs/src/content/docs/spindles/architecture.mdx +++ b/docs/src/content/docs/spindles/architecture.mdx @@ -89,9 +89,9 @@ goes deeper. record op as a push. A repo is accepted when its `sh.tangled.repo` record's `spindle` field -equals `SPINDLE_SERVER_HOSTNAME`, it has a repo DID, its owner is a -spindle member (the server owner or someone the owner added with a -`sh.tangled.spindle.member` record), and it isn't banned. Spindle then +equals `SPINDLE_SERVER_HOSTNAME`, it has a repo DID, its owner is +admitted (an admin, on the allow-list, or anyone when +`SPINDLE_SERVER_ADMISSION=open`), and neither it nor its owner is banned. Spindle then sparse-clones `.tangled/workflows` from the knot over HTTPS. Changing the field to another spindle wipes the repo from this one. diff --git a/docs/src/content/docs/spindles/self-hosting.md b/docs/src/content/docs/spindles/self-hosting.md index ce7b5a32f..e37f0d686 100644 --- a/docs/src/content/docs/spindles/self-hosting.md +++ b/docs/src/content/docs/spindles/self-hosting.md @@ -9,8 +9,9 @@ A spindle is Tangled's CI runner: a Go service (`spindle`) that watches the knots of the repositories it serves, runs their `.tangled/workflows/*.yml` in microVMs or containers, and serves pipeline status and logs over XRPC. Its identity is -`did:web:`, and one DID, the owner, administers -it. +`did:web:`, and its admins (the owner plus +`SPINDLE_SERVER_ADMINS`) administer it. It serves only them and its +allow-list unless `SPINDLE_SERVER_ADMISSION=open`. You don't need your own spindle to run CI; the hosted one works with any knot. Run one when you want CI on your own hardware, or secrets that @@ -139,32 +140,48 @@ unprivileged `spindle-workflow` user with a login shell, and provide `/workspace/repo`. The default image is `nixos-x86_64` for the binary and `nixos` under the NixOS module. -## Membership and repositories +## Admins, admission and repositories -The owner is always a member. To let other people run CI on your -spindle, write one `sh.tangled.spindle.member` record per person in the -owner's PDS, with any atproto record tool: +A spindle doesn't have members, it just agrees to run CI for people. +**Admins** are the owner plus every DID in `SPINDLE_SERVER_ADMINS` +(comma-separated). Admins can edit the allow-list, ban and unban, set +quotas and mint invite codes. -```json -{ - "$type": "sh.tangled.spindle.member", - "subject": "did:plc:friend", - "instance": "spindle.example.com", - "createdAt": "2026-10-07T12:00:00Z" -} -``` +`SPINDLE_SERVER_ADMISSION` decides who else gets CI: + +- `closed` (the default) serves only admins and DIDs on the allow-list. + A fresh spindle serves nobody but its admins until you let people in. +- `open` serves anyone who isn't banned. + +A ban always wins, admins included. There's no "empty allow-list means +everyone" any more: if you ran a spindle that relied on that, set +`SPINDLE_SERVER_ADMISSION=open` or allow-list your people before +upgrading, because repositories of everyone else stop being served +(already stored runs stay). + +To let someone in on a closed spindle, an admin does either of these as a +service-authenticated XRPC call to the spindle: + +- `org.tangled.temp.spindle.allowList.add` with `{"did": "did:plc:friend"}` + (and `.remove` / `.list` to manage the list). +- `org.tangled.temp.server.createInviteCode` with `{"useCount": 1}`. It + answers `{code, expiresAt}`, and the code works once within two + days. The invitee calls `org.tangled.temp.server.redeemInviteCode` with + `{"code": "..."}` as themselves, which puts their DID on the allow-list. + A spent, expired or unknown code is refused with `InviteNotFound`, and a + banned account can't redeem. -`instance` must equal `SPINDLE_SERVER_HOSTNAME` exactly, or spindle -ignores the record. Spindle learns about records from the Jetstream feed -in `SPINDLE_SERVER_JETSTREAM_ENDPOINT` (default: Bluesky's public -Jetstream) and backfills the owner's and members' repositories through -an embedded hydrant (`SPINDLE_SERVER_HYDRANT_DIR`), which fetches those -records from their PDS and only runs while it's catching up. +Spindle learns about records from the Jetstream feed in +`SPINDLE_SERVER_JETSTREAM_ENDPOINT` (default: Bluesky's public +Jetstream) and backfills admins', allow-listed owners' and repo owners' +repositories through an embedded hydrant (`SPINDLE_SERVER_HYDRANT_DIR`), +which fetches those records from their PDS and only runs while it's +catching up. A repository is served when its record names this spindle, the record -carries a repo DID, the owner is a member, and neither the repo nor the -owner is banned. Pointing a repository at a different spindle wipes its -runs, secrets, and webhooks from this one. +carries a repo DID, its owner is admitted as above, and neither the repo +nor the owner is banned. Pointing a repository at a different spindle +wipes its runs, secrets, and webhooks from this one. ## Secrets @@ -306,7 +323,9 @@ reference; the settings most operators touch: | Variable | Default | Meaning | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------- | | `SPINDLE_SERVER_HOSTNAME` | required | Public hostname; identity is `did:web:` | -| `SPINDLE_SERVER_OWNER` | required for standalone and mill | Owner DID | +| `SPINDLE_SERVER_OWNER` | required for standalone and mill | Owner DID, always an admin | +| `SPINDLE_SERVER_ADMINS` | empty | More admin DIDs, comma-separated | +| `SPINDLE_SERVER_ADMISSION` | `closed` | Who else gets CI, `closed` or `open` | | `SPINDLE_SERVER_KEY_PATH` | required for standalone and mill | PEM EC P-256 key behind `/.well-known/did.json` | | `SPINDLE_SERVER_LISTEN_ADDR` | `0.0.0.0:6555` | HTTP and XRPC | | `SPINDLE_SERVER_METRICS_LISTEN_ADDR` | `127.0.0.1:9091` | Prometheus metrics | @@ -392,8 +411,8 @@ format). ## Troubleshooting **A repository's pushes never trigger anything.** Check that its owner -is a member (a `sh.tangled.spindle.member` record with the right -`instance`) and that the repo isn't banned. +is an admin or on the allow-list (or that `SPINDLE_SERVER_ADMISSION` is +`open`) and that neither the repo nor the owner is banned. **Workflows fail with a missing tool or device.** The error names it; see Requirements. In containers, the device mounts and capabilities are diff --git a/lexicons/org/tangled/temp/spindle/allowList/add.json b/lexicons/org/tangled/temp/spindle/allowList/add.json index 78bbfaf64..9aba1762a 100644 --- a/lexicons/org/tangled/temp/spindle/allowList/add.json +++ b/lexicons/org/tangled/temp/spindle/allowList/add.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Add a DID to this given spindle's enrollment-allow-list. This is callable only by a server owner. Sending an empty list will cause enrollment to be open to anyone who isn't directly banned.", + "description": "Add a DID to this spindle's allow-list, so the spindle runs CI for its repos while admission is closed. Only callable by a spindle admin.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/org/tangled/temp/spindle/allowList/list.json b/lexicons/org/tangled/temp/spindle/allowList/list.json index f938776e4..17a1e4cb9 100644 --- a/lexicons/org/tangled/temp/spindle/allowList/list.json +++ b/lexicons/org/tangled/temp/spindle/allowList/list.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Read this given spindle's enrollment-allow-list. Allowed for only server-owners. Empty output indicates that enrollment is open to anyone who isn't directly banned.", + "description": "Read this spindle's allow-list. Only callable by a spindle admin.", "output": { "encoding": "application/json", "schema": { diff --git a/lexicons/org/tangled/temp/spindle/allowList/remove.json b/lexicons/org/tangled/temp/spindle/allowList/remove.json index 30d8b0955..18d5fc5ee 100644 --- a/lexicons/org/tangled/temp/spindle/allowList/remove.json +++ b/lexicons/org/tangled/temp/spindle/allowList/remove.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Remove a DID from this given spindle's enrollment-allow-list. Only callable by server-owner. By removing the last entry, it means reopening enrollment to everyone that's not directly banned.", + "description": "Remove a DID from this spindle's allow-list. Only callable by a spindle admin.", "input": { "encoding": "application/json", "schema": { diff --git a/localinfra/pc/services/spindle/run b/localinfra/pc/services/spindle/run index ebff203c3..0a4237b2e 100755 --- a/localinfra/pc/services/spindle/run +++ b/localinfra/pc/services/spindle/run @@ -5,6 +5,8 @@ mkdir -p "$data/hydrant" "$data/repos" "$data/logs" export SPINDLE_SERVER_HOSTNAME=spindle.tngl.boltless.dev export SPINDLE_SERVER_OWNER=did:plc:igzuueb63dytyy3tpaweofuu +# e2e runs arbitrary test accounts on this spindle +export SPINDLE_SERVER_ADMISSION=open export SPINDLE_SERVER_KEY_PATH=$data/key.pem export SPINDLE_SERVER_LISTEN_ADDR=127.0.0.1:$TNGL_SPINDLE_PORT export SPINDLE_SERVER_METRICS_LISTEN_ADDR=127.0.0.1:$TNGL_SPINDLE_METRICS_PORT diff --git a/nix/modules/spindle.nix b/nix/modules/spindle.nix index 0dda4cdf6..6c14ed915 100644 --- a/nix/modules/spindle.nix +++ b/nix/modules/spindle.nix @@ -69,6 +69,19 @@ in description = "DID of owner (required)"; }; + admins = mkOption { + type = types.listOf types.str; + default = []; + example = ["did:plc:qfpnj4og54vl56wngdriaxug"]; + description = "DIDs that can manage the allow-list, bans, quotas and invite codes, besides the owner."; + }; + + admission = mkOption { + type = types.enum ["closed" "open"]; + default = "closed"; + description = "Who the spindle runs CI for. closed serves only the owner, admins and the allow-list, open serves anyone who isn't banned."; + }; + maxJobCount = mkOption { type = types.int; default = 2; @@ -707,6 +720,8 @@ in "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${instance.server.jetstreamEndpoint}" "SPINDLE_SERVER_DEV=${lib.boolToString instance.server.dev}" "SPINDLE_SERVER_OWNER=${instance.server.owner}" + "SPINDLE_SERVER_ADMINS=${concatStringsSep "," instance.server.admins}" + "SPINDLE_SERVER_ADMISSION=${instance.server.admission}" "SPINDLE_SERVER_MAX_JOB_COUNT=${toString instance.server.maxJobCount}" "SPINDLE_SERVER_CORS_ORIGINS=${concatStringsSep "," instance.server.corsOrigins}" "SPINDLE_SERVER_SECRETS_PROVIDER=${instance.server.secrets.provider}" diff --git a/nix/vm.nix b/nix/vm.nix index 27a417a7a..97a03399e 100644 --- a/nix/vm.nix +++ b/nix/vm.nix @@ -151,6 +151,8 @@ in keyPath = "/var/lib/spindle/key.pem"; server = { owner = envVar "TANGLED_VM_SPINDLE_OWNER"; + # local test accounts all run CI here + admission = "open"; hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; plcUrl = plcUrl; jetstreamEndpoint = jetstream; diff --git a/spindle/allowlist_test.go b/spindle/allowlist_test.go index 01c694449..181914e6a 100644 --- a/spindle/allowlist_test.go +++ b/spindle/allowlist_test.go @@ -4,33 +4,70 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" ) -func TestEmptyAllowlistShouldEnrollEveryone(t *testing.T) { - d, _ := newTestSpindleDB(t) +func TestEnrollsShouldFollowAdmission(t *testing.T) { + admin := syntax.DID("did:plc:admin") + owner := syntax.DID("did:plc:owner") nel := syntax.DID("did:plc:nel") squid := syntax.DID("did:plc:squid") + d, _ := newTestSpindleDB(t) + srv := &config.Server{Owner: owner.String(), Admins: []string{admin.String()}} enrolls := func(want bool, did syntax.DID) { t.Helper() - got, err := db.Enrolls(d, did) + got, err := db.Enrolls(d, srv, did) if err != nil { t.Fatal(err) } if got != want { - t.Fatalf("Enrolls(%s) = %v, want %v", did, got, want) + t.Fatalf("Enrolls(%s) = %v under %q admission, want %v", did, got, srv.Admission, want) } } - enrolls(true, nel) + // an empty allow-list serves only the admins + enrolls(false, nel) + enrolls(true, owner) + enrolls(true, admin) + if err := db.AddToAllowlist(d, squid); err != nil { t.Fatal(err) } + enrolls(true, squid) enrolls(false, nel) + + srv.Admission = config.AdmissionClosed + enrolls(false, nel) + + srv.Admission = config.AdmissionOpen + enrolls(true, nel) enrolls(true, squid) - if err := d.PutBan(db.BanEntry{SubjectDid: squid}); err != nil { +} + +func TestBanShouldBeatEveryAdmission(t *testing.T) { + admin := syntax.DID("did:plc:admin") + owner := syntax.DID("did:plc:owner") + squid := syntax.DID("did:plc:squid") + nel := syntax.DID("did:plc:nel") + + d, _ := newTestSpindleDB(t) + if err := db.AddToAllowlist(d, squid); err != nil { t.Fatal(err) } - enrolls(false, squid) + for _, did := range []syntax.DID{admin, owner, squid, nel} { + if err := d.PutBan(db.BanEntry{SubjectDid: did}); err != nil { + t.Fatal(err) + } + } + + for _, admission := range []config.Admission{config.AdmissionClosed, config.AdmissionOpen} { + srv := &config.Server{Owner: owner.String(), Admins: []string{admin.String()}, Admission: admission} + for _, did := range []syntax.DID{admin, owner, squid, nel} { + if got, err := db.Enrolls(d, srv, did); err != nil || got { + t.Fatalf("Enrolls(%s) = %v, %v under %q admission, want a ban to refuse", did, got, err, admission) + } + } + } } diff --git a/spindle/config/config.go b/spindle/config/config.go index 20fb44f3d..754480596 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "math" + "slices" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -13,23 +15,25 @@ import ( ) type Server struct { - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` - MetricsListenAddr string `env:"METRICS_LISTEN_ADDR, default=127.0.0.1:9091"` - DBPath string `env:"DB_PATH, default=spindle.db"` - RepoDir string `env:"REPO_DIR, default=repos"` - Hostname string `env:"HOSTNAME, required"` - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` - Hydrant Hydrant `env:",prefix=HYDRANT_"` - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` - Dev bool `env:"DEV, default=false"` - DevExtraHosts []string `env:"DEV_EXTRA_HOSTS"` - Owner string `env:"OWNER"` - KeyPath string `env:"KEY_PATH"` // PEM holding the EC P-256 key behind the spindle's did document - Secrets Secrets `env:",prefix=SECRETS_"` - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time - DockerSocket string `env:"DOCKER_SOCKET"` // path to a docker socket to expose to workflow containers - CorsOrigins []string `env:"CORS_ORIGINS"` // origins browsers may call xrpc from, "*" for any. empty leaves cors off + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` + MetricsListenAddr string `env:"METRICS_LISTEN_ADDR, default=127.0.0.1:9091"` + DBPath string `env:"DB_PATH, default=spindle.db"` + RepoDir string `env:"REPO_DIR, default=repos"` + Hostname string `env:"HOSTNAME, required"` + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` + Hydrant Hydrant `env:",prefix=HYDRANT_"` + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` + Dev bool `env:"DEV, default=false"` + DevExtraHosts []string `env:"DEV_EXTRA_HOSTS"` + Owner string `env:"OWNER"` + Admins []string `env:"ADMINS"` // dids that can run the admin routes, on top of the owner + Admission Admission `env:"ADMISSION, default=closed"` // who the spindle runs ci for, besides admins and the allow-list + KeyPath string `env:"KEY_PATH"` // PEM holding the EC P-256 key behind the spindle's did document + Secrets Secrets `env:",prefix=SECRETS_"` + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time + DockerSocket string `env:"DOCKER_SOCKET"` // path to a docker socket to expose to workflow containers + CorsOrigins []string `env:"CORS_ORIGINS"` // origins browsers may call xrpc from, "*" for any. empty leaves cors off } type Hydrant struct { @@ -37,10 +41,27 @@ type Hydrant struct { Dir string `env:"DIR, default=hydrant"` } +// closed serves admins and the allow-list, open serves anyone who isn't banned +type Admission string + +const ( + AdmissionClosed Admission = "closed" + AdmissionOpen Admission = "open" +) + func (s Server) Did() syntax.DID { return serviceauth.DidWeb(s.Hostname) } +func (s Server) IsAdmin(did string) bool { + return did != "" && (did == s.Owner || slices.Contains(s.Admins, did)) +} + +// anything but an explicit open counts as closed, so a bare Server{} fails shut +func (s Server) OpenAdmission() bool { + return s.Admission == AdmissionOpen +} + type Secrets struct { Provider string `env:"PROVIDER, default=sqlite"` OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` @@ -255,6 +276,23 @@ func (c *Config) validate() error { default: return fmt.Errorf("unknown SPINDLE_ROLE %q (want standalone, mill, or executor)", c.Role) } + switch c.Server.Admission { + case AdmissionClosed, AdmissionOpen: + default: + return fmt.Errorf("unknown SPINDLE_SERVER_ADMISSION %q (want closed or open)", c.Server.Admission) + } + admins := c.Server.Admins[:0] + for _, admin := range c.Server.Admins { + admin = strings.TrimSpace(admin) + if admin == "" { + continue + } + if _, err := syntax.ParseDID(admin); err != nil { + return fmt.Errorf("SPINDLE_SERVER_ADMINS has %q, which isn't a did: %w", admin, err) + } + admins = append(admins, admin) + } + c.Server.Admins = admins if c.Mill.DrainTimeout <= 0 { return fmt.Errorf("SPINDLE_MILL_DRAIN_TIMEOUT must be greater than zero") } diff --git a/spindle/config/config_test.go b/spindle/config/config_test.go index 7f7d78e0e..ef51dfa82 100644 --- a/spindle/config/config_test.go +++ b/spindle/config/config_test.go @@ -452,3 +452,82 @@ func TestLoadRequiresSigningKeyForCoordinatorRoles(t *testing.T) { }) } } + +func TestLoadAdmissionDefaultsToClosed(t *testing.T) { + standaloneEnv(t) + + cfg, err := Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if cfg.Server.Admission != AdmissionClosed || cfg.Server.OpenAdmission() { + t.Fatalf("admission = %q, want closed when nothing is set", cfg.Server.Admission) + } +} + +func TestLoadAdmissionParsesOpenAndClosed(t *testing.T) { + standaloneEnv(t) + + for _, want := range []Admission{AdmissionOpen, AdmissionClosed} { + t.Setenv("SPINDLE_SERVER_ADMISSION", string(want)) + cfg, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load rejected %q: %v", want, err) + } + if cfg.Server.Admission != want || cfg.Server.OpenAdmission() != (want == AdmissionOpen) { + t.Fatalf("admission = %q, want %q", cfg.Server.Admission, want) + } + } +} + +func TestLoadRejectsUnknownAdmission(t *testing.T) { + standaloneEnv(t) + + for _, value := range []string{"Open", "everyone", "true", "yes"} { + t.Setenv("SPINDLE_SERVER_ADMISSION", value) + if _, err := Load(context.Background()); err == nil { + t.Fatalf("Load accepted SPINDLE_SERVER_ADMISSION=%s", value) + } + } +} + +func TestLoadAdminsIncludeTheOwner(t *testing.T) { + standaloneEnv(t) + t.Setenv("SPINDLE_SERVER_ADMINS", "did:plc:nel, did:plc:squid,,") + + cfg, err := Load(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, did := range []string{"did:web:spindle.example.com", "did:plc:nel", "did:plc:squid"} { + if !cfg.Server.IsAdmin(did) { + t.Errorf("%s isn't an admin, admins = %v", did, cfg.Server.Admins) + } + } + for _, did := range []string{"did:plc:stranger", ""} { + if cfg.Server.IsAdmin(did) { + t.Errorf("%q is an admin", did) + } + } +} + +func TestLoadAdminsDefaultToJustTheOwner(t *testing.T) { + standaloneEnv(t) + + cfg, err := Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(cfg.Server.Admins) != 0 || !cfg.Server.IsAdmin(cfg.Server.Owner) { + t.Fatalf("admins = %v, want only the owner", cfg.Server.Admins) + } +} + +func TestLoadRejectsAdminsThatAreNotDids(t *testing.T) { + standaloneEnv(t) + t.Setenv("SPINDLE_SERVER_ADMINS", "did:plc:nel,alice.example.com") + + if _, err := Load(context.Background()); err == nil { + t.Fatal("Load accepted a handle in SPINDLE_SERVER_ADMINS") + } +} diff --git a/spindle/db/allowlist.go b/spindle/db/allowlist.go index 57b06c9e2..a5c40bc35 100644 --- a/spindle/db/allowlist.go +++ b/spindle/db/allowlist.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/spindle/config" ) func AddToAllowlist(q DBTX, did syntax.DID) error { @@ -51,19 +52,17 @@ func ListAllowlist(q DBTX) ([]syntax.DID, error) { return dids, rows.Err() } -func Enrolls(q DBTX, did syntax.DID) (bool, error) { +// a ban beats everything, admins are served even when admission is closed +func Enrolls(q DBTX, srv *config.Server, did syntax.DID) (bool, error) { var banned, listed bool - var count int64 err := q.QueryRow(` select exists(select 1 from bans where subject_did = ?), - (select count(*) from instance_allowlist), exists(select 1 from instance_allowlist where did = ?)`, did, did, - ).Scan(&banned, &count, &listed) + ).Scan(&banned, &listed) if err != nil { return false, err } - return !banned && (count == 0 || listed), nil + return !banned && (srv.OpenAdmission() || srv.IsAdmin(did.String()) || listed), nil } - diff --git a/spindle/db/db.go b/spindle/db/db.go index 95ee98b48..8220aed5b 100644 --- a/spindle/db/db.go +++ b/spindle/db/db.go @@ -1188,6 +1188,19 @@ func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error }); err != nil { return err } + if err := orm.RunMigration(conn, logger, "invite-codes", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table if not exists invite_codes ( + code text primary key, + created_by text not null, + expires_at integer not null + ); + create index if not exists idx_invite_codes_expires on invite_codes(expires_at); + `) + return err + }); err != nil { + return err + } return nil } diff --git a/spindle/db/invites.go b/spindle/db/invites.go new file mode 100644 index 000000000..ebb48f8ec --- /dev/null +++ b/spindle/db/invites.go @@ -0,0 +1,62 @@ +package db + +import ( + "database/sql" + "errors" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +var ( + ErrInviteNotFound = errors.New("invite code is unknown, spent or expired") + ErrInviteBanned = errors.New("invite redeemer is banned") +) + +// minting sweeps the expired codes, so nothing else has to +func (d *DB) PutInvite(code string, createdBy syntax.DID, now, expires time.Time) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`delete from invite_codes where expires_at <= ?`, now.Unix()); err != nil { + return err + } + if _, err := tx.Exec( + `insert into invite_codes (code, created_by, expires_at) values (?, ?, ?)`, + code, createdBy, expires.Unix(), + ); err != nil { + return err + } + return tx.Commit() +} + +// the delete is the spend, so of any number of racing redeems only one sees a row +func (d *DB) RedeemInvite(code string, did syntax.DID, now time.Time) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var banned bool + if err := tx.QueryRow(`select exists(select 1 from bans where subject_did = ?)`, did).Scan(&banned); err != nil { + return err + } + if banned { + return ErrInviteBanned + } + var spent string + err = tx.QueryRow(`delete from invite_codes where code = ? and expires_at > ? returning code`, code, now.Unix()).Scan(&spent) + if errors.Is(err, sql.ErrNoRows) { + return ErrInviteNotFound + } + if err != nil { + return err + } + if err := AddToAllowlist(tx, did); err != nil { + return err + } + return tx.Commit() +} diff --git a/spindle/fakes_test.go b/spindle/fakes_test.go index bfac1056f..d40a134df 100644 --- a/spindle/fakes_test.go +++ b/spindle/fakes_test.go @@ -588,6 +588,7 @@ func (w *world) boot() *node { Hydrant: config.Hydrant{Dir: w.hydDir}, Dev: true, Owner: testOwner.String(), + Admission: config.AdmissionOpen, } cfg.ArtifactStores.Disk.Dir = filepath.Join(w.dir, "artifacts") diff --git a/spindle/ingester.go b/spindle/ingester.go index aa2b2325b..8c9af8f7d 100644 --- a/spindle/ingester.go +++ b/spindle/ingester.go @@ -171,7 +171,7 @@ func (s *Spindle) onDeclaration(ctx context.Context, rec *tapc.RecordEventData) if !slices.ContainsFunc(rows, func(r db.Repo) bool { return r.Owner == rec.Did }) { return nil } - } else if enrolled, err := db.Enrolls(s.db, rec.Did); err != nil || !enrolled { + } else if enrolled, err := db.Enrolls(s.db, &s.cfg.Server, rec.Did); err != nil || !enrolled { return err } return s.refreshForEvent(ctx, rec, repoDid) diff --git a/spindle/ingester_test.go b/spindle/ingester_test.go index 4a29f5e9b..f784b8a6b 100644 --- a/spindle/ingester_test.go +++ b/spindle/ingester_test.go @@ -10,6 +10,7 @@ import ( "tangled.org/core/api/org_tangled" "tangled.org/core/api/tangled" + "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/tapc" ) @@ -159,6 +160,7 @@ func TestDeclarationDeleteForARowWithoutARepoDidIsSkipped(t *testing.T) { func TestDeclarationFromAnOutsiderCostsNothing(t *testing.T) { env := newEnsureEnv(t) + env.s.cfg.Server.Admission = config.AdmissionClosed env.serve("repo") if err := db.AddToAllowlist(env.s.db, "did:plc:someoneelse"); err != nil { t.Fatal(err) @@ -174,6 +176,22 @@ func TestDeclarationFromAnOutsiderCostsNothing(t *testing.T) { } } +func TestDeclarationFromAnAdminIsServedWhileClosed(t *testing.T) { + env := newEnsureEnv(t) + env.s.cfg.Server.Admission = config.AdmissionClosed + env.s.cfg.Server.Admins = []string{testOwner.String()} + env.serve("repo") + + declaration := org_tangled.RepoDeclaration{Repo: testRepoDid.String()} + evt := recordEvent(testOwner, org_tangled.RepoDeclarationNSID, "repo", tapc.RecordCreateAction, declaration) + if err := env.s.processEvent(context.Background(), evt); err != nil { + t.Fatal(err) + } + if rows := env.rows(t); len(rows) != 1 { + t.Fatalf("an admin's declaration wasn't served, rows = %+v", rows) + } +} + func TestJetstreamToTapEventMarksRecordsLive(t *testing.T) { tests := []struct { name string diff --git a/spindle/repos.go b/spindle/repos.go index 2358bab95..b2ebe7b8e 100644 --- a/spindle/repos.go +++ b/spindle/repos.go @@ -66,12 +66,12 @@ func (s *Spindle) ensureRepo(ctx context.Context, k knotclient.Knot, repoDid syn l.Warn("refusing banned repo", "owner", owner) return nil, s.wipeRepo(ctx, repoDid, "banned") } - enrolled, err := db.Enrolls(s.db, owner) + enrolled, err := db.Enrolls(s.db, &s.cfg.Server, owner) if err != nil { return nil, fmt.Errorf("checking enrollment: %w", err) } if !enrolled { - l.Warn("rejecting repo: owner isn't on the allow-list", "owner", owner) + l.Warn("rejecting repo: owner isn't admitted", "owner", owner) return nil, nil } diff --git a/spindle/repos_test.go b/spindle/repos_test.go index 4bf8c94a0..201a6feb9 100644 --- a/spindle/repos_test.go +++ b/spindle/repos_test.go @@ -182,6 +182,7 @@ func newEnsureEnv(t *testing.T) *ensureEnv { t.Helper() s := newWipeTestSpindle(t) s.wh = webhook.New(s.db, false) + s.cfg.Server.Admission = config.AdmissionOpen env := &ensureEnv{s: s, knot: newFakeKnot(t)} env.k = knotclient.Knot{Did: syntax.DID("did:web:" + env.knot.host()), Host: env.knot.host()} @@ -516,6 +517,7 @@ func TestEnsureRepoRenameKeepsOneRow(t *testing.T) { func TestEnsureRepoRefusesOwnersOutsideTheAllowList(t *testing.T) { env := newEnsureEnv(t) + env.s.cfg.Server.Admission = config.AdmissionClosed env.serve("repo") if err := db.AddToAllowlist(env.s.db, "did:plc:someoneelse"); err != nil { t.Fatal(err) @@ -529,6 +531,49 @@ func TestEnsureRepoRefusesOwnersOutsideTheAllowList(t *testing.T) { } } +func TestEnsureRepoShouldFollowAdmission(t *testing.T) { + for _, tc := range []struct { + name string + admission config.Admission + admin bool + listed bool + banned bool + served bool + }{ + {name: "closed with an empty allow-list", admission: config.AdmissionClosed}, + {name: "closed and allow-listed", admission: config.AdmissionClosed, listed: true, served: true}, + {name: "closed and an admin", admission: config.AdmissionClosed, admin: true, served: true}, + {name: "closed and banned", admission: config.AdmissionClosed, listed: true, banned: true}, + {name: "closed and a banned admin", admission: config.AdmissionClosed, admin: true, banned: true}, + {name: "open stranger", admission: config.AdmissionOpen, served: true}, + {name: "open and banned", admission: config.AdmissionOpen, banned: true}, + {name: "open and a banned admin", admission: config.AdmissionOpen, admin: true, banned: true}, + } { + t.Run(tc.name, func(t *testing.T) { + env := newEnsureEnv(t) + env.s.cfg.Server.Admission = tc.admission + env.serve("repo") + if tc.admin { + env.s.cfg.Server.Admins = []string{testOwner.String()} + } + if tc.listed { + if err := db.AddToAllowlist(env.s.db, testOwner); err != nil { + t.Fatal(err) + } + } + if tc.banned { + if err := env.s.db.PutBan(db.BanEntry{SubjectDid: testOwner}); err != nil { + t.Fatal(err) + } + } + + if served := env.ensure(t) != nil; served != tc.served { + t.Fatalf("served = %v, want %v, rows = %+v", served, tc.served, env.rows(t)) + } + }) + } +} + func TestEnsureRepoRefusesBannedOwners(t *testing.T) { env := newEnsureEnv(t) env.serve("repo") diff --git a/spindle/server.go b/spindle/server.go index da4aad008..e67b44137 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -298,6 +298,9 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m } spindle.jc = jc jc.AddDid(cfg.Server.Owner) + for _, admin := range cfg.Server.Admins { + jc.AddDid(admin) + } // repo dids aren't known up front, so these skip the did filter jc.ExemptCollection(org_tangled.RepoManifestNSID) jc.ExemptCollection(org_tangled.GraphCollaboratorNSID) @@ -562,11 +565,13 @@ func (s *Spindle) backfillOwners() ([]syntax.DID, error) { dids = append(dids, did) } - owner, err := syntax.ParseDID(s.cfg.Server.Owner) - if err != nil { - s.l.Warn("backfill: invalid configured owner", "owner", s.cfg.Server.Owner, "err", err) - } else { - add(owner) + for _, admin := range append([]string{s.cfg.Server.Owner}, s.cfg.Server.Admins...) { + did, err := syntax.ParseDID(admin) + if err != nil { + s.l.Warn("backfill: invalid configured admin", "admin", admin, "err", err) + continue + } + add(did) } for _, member := range allowed { add(member) diff --git a/spindle/wipe.go b/spindle/wipe.go index 8035ccbd9..3885f1e19 100644 --- a/spindle/wipe.go +++ b/spindle/wipe.go @@ -209,10 +209,8 @@ func (s *Spindle) releaseOwnerInterest(ctx context.Context, ownerDid syntax.DID) if onList { return nil } - // the server owner inherits membership via casbin without an allow-list entry - if ok, err := s.e.IsSpindleOwner(ownerDid.String(), rbac.ThisServer); err != nil { - return err - } else if ok { + // admins are served without an allow-list entry + if s.cfg.Server.IsAdmin(ownerDid.String()) { return nil } if s.backfill != nil { diff --git a/spindle/xrpc/allowlist.go b/spindle/xrpc/allowlist.go index 6256368dd..ad6dfce20 100644 --- a/spindle/xrpc/allowlist.go +++ b/spindle/xrpc/allowlist.go @@ -1,6 +1,7 @@ package xrpc import ( + "context" "encoding/json" "fmt" "net/http" @@ -33,7 +34,7 @@ func allowListInput(w http.ResponseWriter, r *http.Request) (syntax.DID, bool) { } func (x *Xrpc) allowListEntry(w http.ResponseWriter, r *http.Request) (syntax.DID, bool) { - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { writeError(w, *errObj, status) return "", false } @@ -55,15 +56,22 @@ func (x *Xrpc) allowListChange( writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) return } + x.allowListChanged(r.Context(), did, declare, change) + w.WriteHeader(http.StatusOK) +} + +// redeeming an invite writes the allow-list in its own tx, then needs this same follow up +func (x *Xrpc) allowListChanged(ctx context.Context, did syntax.DID, declare func(b OwnerBackfill, did syntax.DID), change string) { if x.Backfill != nil { declare(x.Backfill, did) } - x.Logger.InfoContext(r.Context(), "Allow-list entry "+change+".", "did", did) - w.WriteHeader(http.StatusOK) + x.Logger.InfoContext(ctx, "Allow-list entry "+change+".", "did", did) } +func trackAllowed(b OwnerBackfill, did syntax.DID) { b.Track(did) } + func (x *Xrpc) AllowListAdd(w http.ResponseWriter, r *http.Request) { - x.allowListChange(w, r, db.AddToAllowlist, func(b OwnerBackfill, did syntax.DID) { b.Track(did) }, "added") + x.allowListChange(w, r, db.AddToAllowlist, trackAllowed, "added") } func (x *Xrpc) AllowListRemove(w http.ResponseWriter, r *http.Request) { @@ -71,7 +79,7 @@ func (x *Xrpc) AllowListRemove(w http.ResponseWriter, r *http.Request) { } func (x *Xrpc) AllowListList(w http.ResponseWriter, r *http.Request) { - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { writeError(w, *errObj, status) return } diff --git a/spindle/xrpc/allowlist_test.go b/spindle/xrpc/allowlist_test.go index 3d4156f43..d60db9e14 100644 --- a/spindle/xrpc/allowlist_test.go +++ b/spindle/xrpc/allowlist_test.go @@ -30,7 +30,7 @@ func newAllowListXrpc(t *testing.T) (*Xrpc, *db.DB, *rbac.Enforcer, syntax.DID) Logger: slog.Default(), Db: d, Enforcer: e, - Config: &config.Config{}, + Config: &config.Config{Server: config.Server{Owner: owner.String()}}, }, d, e, owner } diff --git a/spindle/xrpc/ban.go b/spindle/xrpc/ban.go index 2dde5fa26..c2bd993a7 100644 --- a/spindle/xrpc/ban.go +++ b/spindle/xrpc/ban.go @@ -18,7 +18,7 @@ func (x *Xrpc) Ban(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - _, errObj, status := x.checkSpindleOwner(r) + _, errObj, status := x.checkAdmin(r) if errObj != nil { fail(*errObj, status) return @@ -73,7 +73,7 @@ func (x *Xrpc) Unban(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -116,7 +116,7 @@ func (x *Xrpc) GetBan(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -163,7 +163,7 @@ func (x *Xrpc) ListBans(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } diff --git a/spindle/xrpc/ban_test.go b/spindle/xrpc/ban_test.go index 67c511c76..5971eab0b 100644 --- a/spindle/xrpc/ban_test.go +++ b/spindle/xrpc/ban_test.go @@ -55,7 +55,7 @@ func setupBanTestXrpc(t *testing.T) (*Xrpc, *fakeRepoWiper, syntax.DID, syntax.D Logger: slog.Default(), Db: d, Enforcer: e, - Config: &config.Config{}, + Config: &config.Config{Server: config.Server{Owner: ownerDid.String()}}, Wiper: wiper, } diff --git a/spindle/xrpc/invites.go b/spindle/xrpc/invites.go new file mode 100644 index 000000000..3acef50c5 --- /dev/null +++ b/spindle/xrpc/invites.go @@ -0,0 +1,87 @@ +package xrpc + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/org_tangled" + "tangled.org/core/spindle/db" + xrpcerr "tangled.org/core/xrpc/errors" +) + +const ( + inviteTTL = 2 * 24 * time.Hour + inviteCodeBytes = 16 +) + +func (x *Xrpc) now() time.Time { + if x.Now != nil { + return x.Now() + } + return time.Now() +} + +// useCount and forAccount in the input are ignored, a code is always single use +func (x *Xrpc) CreateInviteCode(w http.ResponseWriter, r *http.Request) { + admin, errObj, status := x.checkAdmin(r) + if errObj != nil { + writeError(w, *errObj, status) + return + } + + raw := make([]byte, inviteCodeBytes) + if _, err := rand.Read(raw); err != nil { + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + code := hex.EncodeToString(raw) + now := x.now() + expires := now.Add(inviteTTL) + if err := x.Db.PutInvite(code, admin, now, expires); err != nil { + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + x.Logger.InfoContext(r.Context(), "Minted an invite code.", "by", admin, "expiresAt", expires) + + // no url, spindle has no invite page to link to + x.writeResponse(w, r, http.StatusOK, map[string]string{ + "code": code, + "expiresAt": expires.UTC().Format(time.RFC3339), + }) +} + +func (x *Xrpc) RedeemInviteCode(w http.ResponseWriter, r *http.Request) { + actor, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusUnauthorized) + return + } + var input org_tangled.TempServerRedeemInviteCode_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, invalidRequest(err), http.StatusBadRequest) + return + } + + err := x.Db.RedeemInvite(input.Code, actor, x.now()) + switch { + case errors.Is(err, db.ErrInviteNotFound): + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InviteNotFound"), + xrpcerr.WithMessage("this invite code is unknown, already spent, or expired"), + ), http.StatusBadRequest) + return + case errors.Is(err, db.ErrInviteBanned): + writeError(w, xrpcerr.AccessControlError(actor.String()), http.StatusForbidden) + return + case err != nil: + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + x.allowListChanged(r.Context(), actor, trackAllowed, "added") + w.WriteHeader(http.StatusOK) +} diff --git a/spindle/xrpc/invites_test.go b/spindle/xrpc/invites_test.go new file mode 100644 index 000000000..5b6ec2088 --- /dev/null +++ b/spindle/xrpc/invites_test.go @@ -0,0 +1,267 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/org_tangled" + "tangled.org/core/spindle/db" + "tangled.org/core/spindle/quota" +) + +type inviteReply struct { + Code string `json:"code"` + ExpiresAt string `json:"expiresAt"` +} + +func mint(t *testing.T, x *Xrpc, actor syntax.DID) inviteReply { + t.Helper() + w := allowListCall(x, x.CreateInviteCode, actor, http.MethodPost, org_tangled.TempServerCreateInviteCodeNSID, []byte(`{"useCount":1}`)) + if w.Code != http.StatusOK { + t.Fatalf("mint as %s = %d: %s", actor, w.Code, w.Body.String()) + } + var out inviteReply + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out +} + +func redeem(x *Xrpc, actor syntax.DID, code string) *httptest.ResponseRecorder { + body, _ := json.Marshal(org_tangled.TempServerRedeemInviteCode_Input{Code: code}) + return allowListCall(x, x.RedeemInviteCode, actor, http.MethodPost, org_tangled.TempServerRedeemInviteCodeNSID, body) +} + +func inviteTag(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var out struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %q: %v", w.Body.String(), err) + } + return out.Error +} + +func TestAdminRoutesShouldRefuseNonAdminsAndAcceptListedAdmins(t *testing.T) { + x, d, _, _ := newAllowListXrpc(t) + x.Config.Server.Admins = []string{"did:plc:coadmin"} + x.QuotaStore = db.NewQuotaStore(d, quota.Defaults{}) + x.Wiper = &fakeRepoWiper{} + stranger := syntax.DID("did:plc:stranger") + coadmin := syntax.DID("did:plc:coadmin") + + type call struct { + handler func(http.ResponseWriter, *http.Request) + method string + nsid string + body []byte + } + reads := []call{ + {x.AllowListList, http.MethodGet, org_tangled.TempSpindleAllowListListNSID, nil}, + {x.ListLimits, http.MethodGet, org_tangled.TempSpindleQuotaListNSID, nil}, + {x.GetUsage, http.MethodGet, org_tangled.TempSpindleQuotaUsageNSID, nil}, + {x.ListBans, http.MethodGet, org_tangled.TempSpindleModerationListBansNSID, nil}, + {x.CreateInviteCode, http.MethodPost, org_tangled.TempServerCreateInviteCodeNSID, []byte(`{"useCount":1}`)}, + } + writes := []call{ + {x.AllowListAdd, http.MethodPost, org_tangled.TempSpindleAllowListAddNSID, []byte(`{"did":"did:plc:nel"}`)}, + {x.AllowListRemove, http.MethodPost, org_tangled.TempSpindleAllowListRemoveNSID, []byte(`{"did":"did:plc:nel"}`)}, + {x.SetLimit, http.MethodPost, org_tangled.TempSpindleQuotaSetNSID, []byte(`{}`)}, + {x.UnsetLimit, http.MethodPost, org_tangled.TempSpindleQuotaUnsetNSID, []byte(`{}`)}, + {x.GetLimit, http.MethodGet, org_tangled.TempSpindleQuotaGetNSID, nil}, + {x.Ban, http.MethodPost, org_tangled.TempSpindleModerationBanNSID, []byte(`{}`)}, + {x.Unban, http.MethodPost, org_tangled.TempSpindleModerationUnbanNSID, []byte(`{}`)}, + {x.GetBan, http.MethodGet, org_tangled.TempSpindleModerationGetBanNSID, nil}, + } + + for _, c := range slices.Concat(reads, writes) { + if w := allowListCall(x, c.handler, stranger, c.method, c.nsid, c.body); w.Code != http.StatusUnauthorized { + t.Errorf("stranger on %s = %d: %s", c.nsid, w.Code, w.Body.String()) + } + } + for _, c := range reads { + if w := allowListCall(x, c.handler, coadmin, c.method, c.nsid, c.body); w.Code != http.StatusOK { + t.Errorf("admins entry on %s = %d: %s", c.nsid, w.Code, w.Body.String()) + } + } +} + +func TestInviteShouldAdmitAStrangerOnce(t *testing.T) { + x, d, _, owner := newAllowListXrpc(t) + f := &fakeBackfill{} + x.Backfill = f + x.Config.Server.Hostname = "spindle.example.com" + stranger := syntax.DID("did:plc:stranger") + second := syntax.DID("did:plc:second") + + invite := mint(t, x, owner) + if len(invite.Code) != 32 || invite.ExpiresAt == "" { + t.Fatalf("invite = %+v", invite) + } + if _, err := time.Parse(time.RFC3339, invite.ExpiresAt); err != nil { + t.Fatalf("expiresAt %q: %v", invite.ExpiresAt, err) + } + + if w := redeem(x, stranger, invite.Code); w.Code != http.StatusOK { + t.Fatalf("redeem = %d: %s", w.Code, w.Body.String()) + } + if listed, _ := db.AllowlistContains(d, stranger); !listed { + t.Fatal("a redeemed invite didn't allow-list the caller") + } + if enrolled, err := db.Enrolls(d, &x.Config.Server, stranger); err != nil || !enrolled { + t.Fatalf("Enrolls = %v, %v after redeeming", enrolled, err) + } + if len(f.tracked) != 1 || f.tracked[0] != stranger { + t.Fatalf("backfill tracked %v, want the redeemer", f.tracked) + } + + w := redeem(x, second, invite.Code) + if w.Code != http.StatusBadRequest || inviteTag(t, w) != "InviteNotFound" { + t.Fatalf("second redeem = %d: %s", w.Code, w.Body.String()) + } + if listed, _ := db.AllowlistContains(d, second); listed { + t.Fatal("a spent code admitted a second account") + } +} + +func TestInviteShouldBeRefusedWhenUnknownOrExpired(t *testing.T) { + x, _, _, owner := newAllowListXrpc(t) + now := time.Unix(1_800_000_000, 0) + x.Now = func() time.Time { return now } + stranger := syntax.DID("did:plc:stranger") + + if w := redeem(x, stranger, "nope"); w.Code != http.StatusBadRequest || inviteTag(t, w) != "InviteNotFound" { + t.Fatalf("unknown code = %d: %s", w.Code, w.Body.String()) + } + + stale := mint(t, x, owner) + now = now.Add(inviteTTL) + if w := redeem(x, stranger, stale.Code); w.Code != http.StatusBadRequest || inviteTag(t, w) != "InviteNotFound" { + t.Fatalf("expired code = %d: %s", w.Code, w.Body.String()) + } + + // minting again sweeps the dead row + now = now.Add(time.Second) + mint(t, x, owner) + var n int + if err := x.Db.QueryRow(`select count(*) from invite_codes where code = ?`, stale.Code).Scan(&n); err != nil || n != 0 { + t.Fatalf("expired code rows = %d, %v after a mint", n, err) + } + + fresh := mint(t, x, owner) + now = now.Add(inviteTTL - time.Second) + if w := redeem(x, stranger, fresh.Code); w.Code != http.StatusOK { + t.Fatalf("code one second before expiry = %d: %s", w.Code, w.Body.String()) + } +} + +func TestInviteConcurrentRedeemsShouldHaveOneWinner(t *testing.T) { + x, d, _, owner := newAllowListXrpc(t) + invite := mint(t, x, owner) + + const racers = 16 + codes := make([]int, racers) + var wg sync.WaitGroup + for i := range racers { + wg.Add(1) + go func() { + defer wg.Done() + did := syntax.DID("did:plc:racer" + string(rune('a'+i))) + codes[i] = redeem(x, did, invite.Code).Code + }() + } + wg.Wait() + + wins := 0 + for _, code := range codes { + if code == http.StatusOK { + wins++ + } + } + if wins != 1 { + t.Fatalf("%d redeems of one code won, statuses = %v", wins, codes) + } + listed, err := db.ListAllowlist(d) + if err != nil || len(listed) != 1 { + t.Fatalf("allow-list = %v, %v, want just the winner", listed, err) + } +} + +func TestInviteShouldNotAdmitABannedCaller(t *testing.T) { + x, d, _, owner := newAllowListXrpc(t) + banned := syntax.DID("did:plc:banned") + if err := d.PutBan(db.BanEntry{SubjectDid: banned}); err != nil { + t.Fatal(err) + } + invite := mint(t, x, owner) + + if w := redeem(x, banned, invite.Code); w.Code != http.StatusForbidden { + t.Fatalf("banned redeem = %d: %s", w.Code, w.Body.String()) + } + if listed, _ := db.AllowlistContains(d, banned); listed { + t.Fatal("a banned caller got on the allow-list") + } + // the refusal mustn't burn the code + if w := redeem(x, "did:plc:stranger", invite.Code); w.Code != http.StatusOK { + t.Fatalf("code after a banned attempt = %d: %s", w.Code, w.Body.String()) + } +} + +func TestInviteShouldAcceptAnAlreadyAllowListedCaller(t *testing.T) { + x, d, _, owner := newAllowListXrpc(t) + squid := syntax.DID("did:plc:squid") + if err := db.AddToAllowlist(d, squid); err != nil { + t.Fatal(err) + } + invite := mint(t, x, owner) + + if w := redeem(x, squid, invite.Code); w.Code != http.StatusOK { + t.Fatalf("redeem while listed = %d: %s", w.Code, w.Body.String()) + } + if w := redeem(x, squid, invite.Code); w.Code != http.StatusBadRequest { + t.Fatalf("the code survived a redeem, %d", w.Code) + } +} + +func TestInviteShouldBeMintedByAdminsOnly(t *testing.T) { + x, d, _, _ := newAllowListXrpc(t) + w := allowListCall(x, x.CreateInviteCode, "did:plc:stranger", http.MethodPost, org_tangled.TempServerCreateInviteCodeNSID, []byte(`{"useCount":1}`)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("stranger mint = %d: %s", w.Code, w.Body.String()) + } + var n int + if err := d.QueryRow(`select count(*) from invite_codes`).Scan(&n); err != nil || n != 0 { + t.Fatalf("invite rows = %d, %v after a refused mint", n, err) + } +} + +func TestInviteShouldSurviveARestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "spindle.db") + open := func() *db.DB { + d, err := db.Make(t.Context(), path) + if err != nil { + t.Fatal(err) + } + return d + } + first := open() + now := time.Unix(1_800_000_000, 0) + if err := first.PutInvite("abc", "did:plc:admin", now, now.Add(inviteTTL)); err != nil { + t.Fatal(err) + } + first.Close() + + second := open() + defer second.Close() + if err := second.RedeemInvite("abc", "did:plc:stranger", now.Add(time.Hour)); err != nil { + t.Fatalf("redeem after reopening the db: %v", err) + } +} diff --git a/spindle/xrpc/quota.go b/spindle/xrpc/quota.go index 7a08b82c0..33dfae3e1 100644 --- a/spindle/xrpc/quota.go +++ b/spindle/xrpc/quota.go @@ -8,26 +8,19 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/org_tangled" - "tangled.org/core/rbac" "tangled.org/core/spindle/db" "tangled.org/core/spindle/quota" xrpcerr "tangled.org/core/xrpc/errors" ) -func (x *Xrpc) checkSpindleOwner(r *http.Request) (syntax.DID, *xrpcerr.XrpcError, int) { +func (x *Xrpc) checkAdmin(r *http.Request) (syntax.DID, *xrpcerr.XrpcError, int) { actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { err := xrpcerr.MissingActorDidError return "", &err, http.StatusUnauthorized } - isOwner, err := x.Enforcer.IsSpindleOwner(actorDid.String(), rbac.ThisServer) - if err != nil { - x.Logger.ErrorContext(r.Context(), "failed to check spindle owner", "did", actorDid.String(), "err", err) - errObj := xrpcerr.GenericError(err) - return "", &errObj, http.StatusInternalServerError - } - if !isOwner { + if !x.Config.Server.IsAdmin(actorDid.String()) { x.Logger.ErrorContext(r.Context(), "insufficient permissions", "did", actorDid.String()) errObj := xrpcerr.AccessControlError(actorDid.String()) return "", &errObj, http.StatusUnauthorized @@ -53,7 +46,7 @@ func (x *Xrpc) SetLimit(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -117,7 +110,7 @@ func (x *Xrpc) UnsetLimit(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -159,7 +152,7 @@ func (x *Xrpc) GetLimit(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -211,7 +204,7 @@ func (x *Xrpc) ListLimits(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } @@ -252,7 +245,7 @@ func (x *Xrpc) GetUsage(w http.ResponseWriter, r *http.Request) { writeError(w, e, status) } - if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + if _, errObj, status := x.checkAdmin(r); errObj != nil { fail(*errObj, status) return } diff --git a/spindle/xrpc/quota_test.go b/spindle/xrpc/quota_test.go index 7c84292b5..1943a99a9 100644 --- a/spindle/xrpc/quota_test.go +++ b/spindle/xrpc/quota_test.go @@ -38,7 +38,7 @@ func setupQuotaTestXrpc(t *testing.T) (*Xrpc, syntax.DID, syntax.DID) { Logger: slog.Default(), Db: d, Enforcer: e, - Config: &config.Config{}, + Config: &config.Config{Server: config.Server{Owner: ownerDid.String()}}, QuotaStore: qs, } diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index f9f08892c..f4bad1442 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "sync" + "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" @@ -89,6 +90,7 @@ type Xrpc struct { Knots *knotclient.Client Events EventNotifier Repos RepoRefresher + Now func() time.Time // defaults to time.Now unknownOnce sync.Once unknown *expirable.LRU[syntax.DID, struct{}] @@ -140,6 +142,8 @@ func (x *Xrpc) Router() http.Handler { r.Post("/"+org_tangled.TempSpindleAllowListAddNSID, x.AllowListAdd) r.Post("/"+org_tangled.TempSpindleAllowListRemoveNSID, x.AllowListRemove) r.Get("/"+org_tangled.TempSpindleAllowListListNSID, x.AllowListList) + r.Post("/"+org_tangled.TempServerCreateInviteCodeNSID, x.CreateInviteCode) + r.Post("/"+org_tangled.TempServerRedeemInviteCodeNSID, x.RedeemInviteCode) r.Post("/"+org_tangled.TempWebhookCreateWebhookNSID, x.WebhookCreate) r.Post("/"+org_tangled.TempWebhookUpdateWebhookNSID, x.WebhookUpdate) -- 2.54.0