This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle/db: acl table migration

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 29, 2026, 5:25 PM +0900) commit 769fe59f parent 967247a8 change-id nuuoonnq
+322
+63
spindle/db/db.go
··· 329 329 return err 330 330 } 331 331 332 + if err := orm.RunMigration(conn, logger, "switch-to-rbac-v2", func(tx *sql.Tx) error { 333 + // NOTE: we are manually creating casbin table because DB migration will happen before rbac/v2 initialization. 334 + if _, err := tx.Exec(` 335 + CREATE TABLE acl_2( 336 + p_type VARCHAR(32) DEFAULT '' NOT NULL, 337 + v0 VARCHAR(255) DEFAULT '' NOT NULL, 338 + v1 VARCHAR(255) DEFAULT '' NOT NULL, 339 + v2 VARCHAR(255) DEFAULT '' NOT NULL, 340 + v3 VARCHAR(255) DEFAULT '' NOT NULL, 341 + v4 VARCHAR(255) DEFAULT '' NOT NULL, 342 + v5 VARCHAR(255) DEFAULT '' NOT NULL, 343 + CHECK (TYPEOF("p_type") = "text" AND 344 + LENGTH("p_type") <= 32), 345 + CHECK (TYPEOF("v0") = "text" AND 346 + LENGTH("v0") <= 255), 347 + CHECK (TYPEOF("v1") = "text" AND 348 + LENGTH("v1") <= 255), 349 + CHECK (TYPEOF("v2") = "text" AND 350 + LENGTH("v2") <= 255), 351 + CHECK (TYPEOF("v3") = "text" AND 352 + LENGTH("v3") <= 255), 353 + CHECK (TYPEOF("v4") = "text" AND 354 + LENGTH("v4") <= 255), 355 + CHECK (TYPEOF("v5") = "text" AND 356 + LENGTH("v5") <= 255) 357 + ); 358 + `); err != nil { 359 + return err 360 + } 361 + 362 + // fresh spindles might not have acl table 363 + hasAcl, err := tableExists(tx, "acl") 364 + if err != nil { 365 + return err 366 + } 367 + if !hasAcl { 368 + return nil 369 + } 370 + 371 + for _, role := range []string{"repo:owner", "repo:collaborator"} { 372 + if _, err := tx.Exec(` 373 + insert into acl_2 (p_type, v0, v1, v2) 374 + select distinct 'g', v0, v3, v2 375 + from acl 376 + where p_type = 'p' and v1 = 'thisserver' and v3 = ? 377 + `, role); err != nil { 378 + return err 379 + } 380 + } 381 + return nil 382 + }); err != nil { 383 + return err 384 + } 385 + 332 386 return nil 387 + } 388 + 389 + func tableExists(tx *sql.Tx, name string) (bool, error) { 390 + var exists bool 391 + err := tx.QueryRow( 392 + `select exists (select 1 from sqlite_master where type = 'table' and name = ?)`, 393 + name, 394 + ).Scan(&exists) 395 + return exists, err 333 396 } 334 397 335 398 func hasUniqueIndex(tx *sql.Tx, table string, cols []string) (bool, error) {
+259
spindle/db/migrations_test.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "path/filepath" 7 + "slices" 8 + "testing" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/rbac/v2" 12 + ) 13 + 14 + // seedLegacyDB writes a pre-rbac/v2 spindle database: legacy `repos`, the tables that used to 15 + // hold ACL state, and a casbin `acl` table as rbac.NewEnforcer would have left it. 16 + func seedLegacyDB(t *testing.T, path string) { 17 + t.Helper() 18 + raw, err := sql.Open("sqlite3", path) 19 + if err != nil { 20 + t.Fatalf("open: %v", err) 21 + } 22 + defer raw.Close() 23 + 24 + if _, err := raw.Exec(` 25 + create table migrations ( 26 + id integer primary key autoincrement, 27 + name text unique 28 + ); 29 + 30 + create table known_dids (did text primary key); 31 + 32 + create table spindle_members ( 33 + id integer primary key autoincrement, 34 + did text not null, 35 + rkey text not null, 36 + instance text not null, 37 + subject text not null, 38 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 39 + unique (did, rkey) 40 + ); 41 + 42 + create table repos ( 43 + id integer primary key autoincrement, 44 + knot text not null, 45 + owner text not null, 46 + rkey text not null, 47 + repo_did text, 48 + created_at text, 49 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 50 + unique(owner, rkey) 51 + ); 52 + 53 + create table repo_collaborators ( 54 + id integer primary key autoincrement, 55 + owner_did text not null, 56 + rkey text not null, 57 + subject text not null, 58 + repo_did text not null, 59 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 60 + unique(owner_did, rkey) 61 + ); 62 + 63 + create table acl ( 64 + p_type text default '' not null, 65 + v0 text default '' not null, 66 + v1 text default '' not null, 67 + v2 text default '' not null, 68 + v3 text default '' not null, 69 + v4 text default '' not null, 70 + v5 text default '' not null 71 + ); 72 + 73 + -- one repo_did with two rkeys (rename/alias siblings) plus a row that never got a did 74 + insert into repos (knot, owner, rkey, repo_did, created_at) values 75 + ('knot.test', 'did:plc:alice', 'old-rkey', 'did:plc:repo1', '2024-01-01T00:00:00Z'), 76 + ('knot.test', 'did:plc:alice', 'new-rkey', 'did:plc:repo1', '2024-06-01T00:00:00Z'), 77 + ('knot.test', 'did:plc:alice', 'no-did-rkey', null, null); 78 + 79 + -- did = whoever published the record (the spindle owner), subject = the member 80 + insert into spindle_members (did, rkey, instance, subject) values 81 + ('did:plc:owner', '3kmember001', 'spindle.test', 'did:plc:member'), 82 + ('did:plc:owner', '3kmember002', 'spindle.test', 'did:plc:member2'); 83 + 84 + -- known_dids mixed collaborators in with members, so it must not seed the members table 85 + insert into known_dids (did) values 86 + ('did:plc:member'), 87 + ('did:plc:bob'); 88 + 89 + insert into acl (p_type, v0, v1, v2, v3) values 90 + ('g', 'did:plc:owner', 'server:owner', 'spindle:spindle.test', ''), 91 + ('g', 'did:plc:member', 'server:member', 'spindle:spindle.test', ''), 92 + ('g', 'did:plc:member2', 'server:member', 'spindle:spindle.test', ''), 93 + -- repo policies, deliberately duplicated to exercise the distinct 94 + ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:owner'), 95 + ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:push'), 96 + ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:settings'), 97 + ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:collaborator'), 98 + ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:push'), 99 + ('p', 'server:owner', 'thisserver', 'did:plc:repo1', 'repo:delete'); 100 + `); err != nil { 101 + t.Fatalf("seed: %v", err) 102 + } 103 + } 104 + 105 + func TestMigrateLegacyAcl(t *testing.T) { 106 + path := filepath.Join(t.TempDir(), "spindle.db") 107 + seedLegacyDB(t, path) 108 + 109 + d, err := Make(context.Background(), path) 110 + if err != nil { 111 + t.Fatalf("Make: %v", err) 112 + } 113 + defer d.Close() 114 + 115 + t.Run("collapses repo_did siblings", func(t *testing.T) { 116 + repo, err := d.GetRepoByDid("did:plc:repo1") 117 + if err != nil { 118 + t.Fatalf("GetRepoByDid: %v", err) 119 + } 120 + // prefers the row with the latest created_at, like the old CollapseRepoSiblings 121 + if repo.Rkey != "new-rkey" { 122 + t.Errorf("kept rkey %q, want new-rkey", repo.Rkey) 123 + } 124 + 125 + var n int 126 + if err := d.QueryRow(`select count(*) from repos`).Scan(&n); err != nil { 127 + t.Fatalf("count repos: %v", err) 128 + } 129 + if n != 1 { 130 + t.Errorf("repos has %d rows, want 1 (siblings collapsed, did-less row dropped)", n) 131 + } 132 + }) 133 + 134 + t.Run("seeds members from spindle_members", func(t *testing.T) { 135 + members, err := d.ListAllowedMembers() 136 + if err != nil { 137 + t.Fatalf("ListAllowedMembers: %v", err) 138 + } 139 + 140 + // `subject` is the member, `did` is the inviting owner - both are members 141 + for _, want := range []syntax.DID{"did:plc:member", "did:plc:member2", "did:plc:owner"} { 142 + if !slices.Contains(members, want) { 143 + t.Errorf("members missing %s, got %v", want, members) 144 + } 145 + } 146 + 147 + // known_dids also held collaborators; seeding `members` from it would promote them 148 + // to spindle members, i.e. let them register their own repos 149 + if slices.Contains(members, "did:plc:bob") { 150 + t.Errorf("collaborator did:plc:bob was granted spindle membership, got %v", members) 151 + } 152 + }) 153 + 154 + t.Run("rewrites acl into acl_2 grouping rows", func(t *testing.T) { 155 + rows, err := d.Query(`select p_type, v0, v1, v2 from acl_2 order by v1, v0`) 156 + if err != nil { 157 + t.Fatalf("query acl_2: %v", err) 158 + } 159 + defer rows.Close() 160 + 161 + var got []string 162 + for rows.Next() { 163 + var pType, v0, v1, v2 string 164 + if err := rows.Scan(&pType, &v0, &v1, &v2); err != nil { 165 + t.Fatalf("scan: %v", err) 166 + } 167 + got = append(got, pType+"|"+v0+"|"+v1+"|"+v2) 168 + } 169 + if err := rows.Err(); err != nil { 170 + t.Fatalf("rows: %v", err) 171 + } 172 + 173 + want := []string{ 174 + "g|did:plc:bob|repo:collaborator|did:plc:repo1", 175 + "g|did:plc:alice|repo:owner|did:plc:repo1", 176 + } 177 + if len(got) != len(want) { 178 + t.Fatalf("acl_2 rows = %v, want %v", got, want) 179 + } 180 + for i := range want { 181 + if got[i] != want[i] { 182 + t.Errorf("acl_2 row %d = %q, want %q", i, got[i], want[i]) 183 + } 184 + } 185 + }) 186 + 187 + // the point of the rewrite: migrated grants have to actually enforce 188 + t.Run("migrated grants enforce", func(t *testing.T) { 189 + e, err := rbac.NewEnforcer(path) 190 + if err != nil { 191 + t.Fatalf("rbac.NewEnforcer: %v", err) 192 + } 193 + 194 + for _, tc := range []struct { 195 + name string 196 + did syntax.DID 197 + want bool 198 + }{ 199 + {"owner", "did:plc:alice", true}, 200 + {"collaborator", "did:plc:bob", true}, 201 + {"stranger", "did:plc:eve", false}, 202 + } { 203 + ok, err := e.IsRepoSecretsAllowed(tc.did, "did:plc:repo1") 204 + if err != nil { 205 + t.Fatalf("IsRepoSecretsAllowed(%s): %v", tc.name, err) 206 + } 207 + if ok != tc.want { 208 + t.Errorf("IsRepoSecretsAllowed(%s) = %v, want %v", tc.name, ok, tc.want) 209 + } 210 + 211 + ok, err = e.IsRepoCiTriggerAllowed(tc.did, "did:plc:repo1") 212 + if err != nil { 213 + t.Fatalf("IsRepoCiTriggerAllowed(%s): %v", tc.name, err) 214 + } 215 + if ok != tc.want { 216 + t.Errorf("IsRepoCiTriggerAllowed(%s) = %v, want %v", tc.name, ok, tc.want) 217 + } 218 + } 219 + }) 220 + 221 + // the base schema recreates these empty on every boot, so assert the stale rows are gone 222 + // rather than the tables - reopen to get past the recreate. 223 + t.Run("clears legacy acl tables", func(t *testing.T) { 224 + d.Close() 225 + reopened, err := Make(context.Background(), path) 226 + if err != nil { 227 + t.Fatalf("reopen: %v", err) 228 + } 229 + defer reopened.Close() 230 + 231 + for _, table := range []string{"known_dids", "repo_collaborators", "spindle_members"} { 232 + var n int 233 + if err := reopened.QueryRow(`select count(*) from ` + table).Scan(&n); err != nil { 234 + t.Fatalf("count %s: %v", table, err) 235 + } 236 + if n != 0 { 237 + t.Errorf("table %s still has %d stale rows", table, n) 238 + } 239 + } 240 + }) 241 + } 242 + 243 + // TestMigrateFreshDB covers the case where no `acl` table exists yet: it is created by the 244 + // casbin adapter inside rbac.NewEnforcer, which runs after Make. 245 + func TestMigrateFreshDB(t *testing.T) { 246 + d, err := Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) 247 + if err != nil { 248 + t.Fatalf("Make on fresh db: %v", err) 249 + } 250 + defer d.Close() 251 + 252 + members, err := d.ListAllowedMembers() 253 + if err != nil { 254 + t.Fatalf("ListAllowedMembers: %v", err) 255 + } 256 + if len(members) != 0 { 257 + t.Errorf("fresh db has members %v, want none", members) 258 + } 259 + }