forked from tangled.org/core
Monorepo for Tangled

rbac2: rbac enforcer rewrite

- use repo AT-URI as identifier
- use `dom` field rather than `obj` to filter by repository
So now it's "user with role A in repo B can do action D to object C"
where `A,B,C,D` are `sub,dom,obj,act`.

Many parts are missing, but should be enough to test this new RBAC
enforcer package in spindle.

Related issue: <https://tangled.org/tangled.org/core/issues/282>

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

boltless.me cbecd6e4 3a99e385

verified
+2
appview/db/follow.go
··· 167 if err != nil { 168 return nil, err 169 } 170 for rows.Next() { 171 var follow models.Follow 172 var followedAt string
··· 167 if err != nil { 168 return nil, err 169 } 170 + defer rows.Close() 171 + 172 for rows.Next() { 173 var follow models.Follow 174 var followedAt string
+1
appview/db/issues.go
··· 452 if err != nil { 453 return nil, err 454 } 455 456 for rows.Next() { 457 var comment models.IssueComment
··· 452 if err != nil { 453 return nil, err 454 } 455 + defer rows.Close() 456 457 for rows.Next() { 458 var comment models.IssueComment
+1 -1
appview/db/language.go
··· 28 whereClause, 29 ) 30 rows, err := e.Query(query, args...) 31 - 32 if err != nil { 33 return nil, fmt.Errorf("failed to execute query: %w ", err) 34 } 35 36 var langs []models.RepoLanguage 37 for rows.Next() {
··· 28 whereClause, 29 ) 30 rows, err := e.Query(query, args...) 31 if err != nil { 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 } 34 + defer rows.Close() 35 36 var langs []models.RepoLanguage 37 for rows.Next() {
+5
appview/db/profile.go
··· 230 if err != nil { 231 return nil, err 232 } 233 234 profileMap := make(map[string]*models.Profile) 235 for rows.Next() { ··· 270 if err != nil { 271 return nil, err 272 } 273 idxs := make(map[string]int) 274 for did := range profileMap { 275 idxs[did] = 0 ··· 290 if err != nil { 291 return nil, err 292 } 293 idxs = make(map[string]int) 294 for did := range profileMap { 295 idxs[did] = 0
··· 230 if err != nil { 231 return nil, err 232 } 233 + defer rows.Close() 234 235 profileMap := make(map[string]*models.Profile) 236 for rows.Next() { ··· 271 if err != nil { 272 return nil, err 273 } 274 + defer rows.Close() 275 + 276 idxs := make(map[string]int) 277 for did := range profileMap { 278 idxs[did] = 0 ··· 293 if err != nil { 294 return nil, err 295 } 296 + defer rows.Close() 297 + 298 idxs = make(map[string]int) 299 for did := range profileMap { 300 idxs[did] = 0
+1
appview/db/registration.go
··· 38 if err != nil { 39 return nil, err 40 } 41 42 for rows.Next() { 43 var createdAt string
··· 38 if err != nil { 39 return nil, err 40 } 41 + defer rows.Close() 42 43 for rows.Next() { 44 var createdAt string
+11 -1
appview/db/repos.go
··· 56 limitClause, 57 ) 58 rows, err := e.Query(repoQuery, args...) 59 - 60 if err != nil { 61 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 62 } 63 64 for rows.Next() { 65 var repo models.Repo ··· 128 if err != nil { 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 } 131 for rows.Next() { 132 var repoat, labelat string 133 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 165 if err != nil { 166 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 167 } 168 for rows.Next() { 169 var repoat, lang string 170 if err := rows.Scan(&repoat, &lang); err != nil { ··· 191 if err != nil { 192 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 193 } 194 for rows.Next() { 195 var repoat string 196 var count int ··· 220 if err != nil { 221 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 222 } 223 for rows.Next() { 224 var repoat string 225 var open, closed int ··· 261 if err != nil { 262 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 263 } 264 for rows.Next() { 265 var repoat string 266 var open, merged, closed, deleted int
··· 56 limitClause, 57 ) 58 rows, err := e.Query(repoQuery, args...) 59 if err != nil { 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 61 } 62 + defer rows.Close() 63 64 for rows.Next() { 65 var repo models.Repo ··· 128 if err != nil { 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 } 131 + defer rows.Close() 132 + 133 for rows.Next() { 134 var repoat, labelat string 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 167 if err != nil { 168 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 169 } 170 + defer rows.Close() 171 + 172 for rows.Next() { 173 var repoat, lang string 174 if err := rows.Scan(&repoat, &lang); err != nil { ··· 195 if err != nil { 196 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 197 } 198 + defer rows.Close() 199 + 200 for rows.Next() { 201 var repoat string 202 var count int ··· 226 if err != nil { 227 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 228 } 229 + defer rows.Close() 230 + 231 for rows.Next() { 232 var repoat string 233 var open, closed int ··· 269 if err != nil { 270 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 271 } 272 + defer rows.Close() 273 + 274 for rows.Next() { 275 var repoat string 276 var open, merged, closed, deleted int
+1
appview/db/star.go
··· 165 if err != nil { 166 return nil, err 167 } 168 169 starMap := make(map[string][]models.Star) 170 for rows.Next() {
··· 165 if err != nil { 166 return nil, err 167 } 168 + defer rows.Close() 169 170 starMap := make(map[string][]models.Star) 171 for rows.Next() {
-1
appview/notify/merged_notifier.go
··· 39 v.Call(in) 40 }(n) 41 } 42 - wg.Wait() 43 } 44 45 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
··· 39 v.Call(in) 40 }(n) 41 } 42 } 43 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+6 -1
appview/pages/funcmap.go
··· 25 "github.com/dustin/go-humanize" 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 "tangled.org/core/appview/filetree" 29 "tangled.org/core/appview/models" 30 "tangled.org/core/appview/pages/markup" ··· 261 }, 262 "description": func(text string) template.HTML { 263 p.rctx.RendererType = markup.RendererTypeDefault 264 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 265 sanitized := p.rctx.SanitizeDescription(htmlString) 266 return template.HTML(sanitized) 267 },
··· 25 "github.com/dustin/go-humanize" 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 29 "tangled.org/core/appview/filetree" 30 "tangled.org/core/appview/models" 31 "tangled.org/core/appview/pages/markup" ··· 262 }, 263 "description": func(text string) template.HTML { 264 p.rctx.RendererType = markup.RendererTypeDefault 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 270 sanitized := p.rctx.SanitizeDescription(htmlString) 271 return template.HTML(sanitized) 272 },
+2
appview/pages/markup/markdown.go
··· 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 "github.com/yuin/goldmark" 16 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 "github.com/yuin/goldmark/ast" 18 "github.com/yuin/goldmark/extension" ··· 66 ), 67 callout.CalloutExtention, 68 textension.AtExt, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(),
··· 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 "github.com/yuin/goldmark/ast" 19 "github.com/yuin/goldmark/extension" ··· 67 ), 68 callout.CalloutExtention, 69 textension.AtExt, 70 + emoji.Emoji, 71 ), 72 goldmark.WithParserOptions( 73 parser.WithAutoHeadingID(),
+1 -1
appview/pages/pages.go
··· 640 } 641 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn", w, params) 644 } 645 646 type RepoIndexParams struct {
··· 640 } 641 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 + return p.executePlain("fragments/starBtn-oob", w, params) 644 } 645 646 type RepoIndexParams struct {
+5
appview/pages/templates/fragments/starBtn-oob.html
···
··· 1 + {{ define "fragments/starBtn-oob" }} 2 + <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 + {{ template "fragments/starBtn" . }} 4 + </div> 5 + {{ end }}
+1 -3
appview/pages/templates/fragments/starBtn.html
··· 1 {{ define "fragments/starBtn" }} 2 <button 3 id="starBtn" 4 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 10 {{ end }} 11 12 hx-trigger="click" 13 - hx-target="this" 14 - hx-swap="outerHTML" 15 - hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 hx-disabled-elt="#starBtn" 17 > 18 {{ if .IsStarred }}
··· 1 {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 3 <button 4 id="starBtn" 5 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 11 {{ end }} 12 13 hx-trigger="click" 14 hx-disabled-elt="#starBtn" 15 > 16 {{ if .IsStarred }}
+1 -1
appview/pages/templates/knots/index.html
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 + href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
+1 -1
appview/pages/templates/repo/empty.html
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
+6 -6
appview/pages/templates/repo/fragments/backlinks.html
··· 14 <div class="flex gap-2 items-center"> 15 {{ if .State.IsClosed }} 16 <span class="text-gray-500 dark:text-gray-400"> 17 - {{ i "ban" "w-4 h-4" }} 18 </span> 19 {{ else if eq .Kind.String "issues" }} 20 <span class="text-green-600 dark:text-green-500"> 21 - {{ i "circle-dot" "w-4 h-4" }} 22 </span> 23 {{ else if .State.IsOpen }} 24 <span class="text-green-600 dark:text-green-500"> 25 - {{ i "git-pull-request" "w-4 h-4" }} 26 </span> 27 {{ else if .State.IsMerged }} 28 <span class="text-purple-600 dark:text-purple-500"> 29 - {{ i "git-merge" "w-4 h-4" }} 30 </span> 31 {{ else }} 32 <span class="text-gray-600 dark:text-gray-300"> 33 - {{ i "git-pull-request-closed" "w-4 h-4" }} 34 </span> 35 {{ end }} 36 - <a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 </div> 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 <div>
··· 14 <div class="flex gap-2 items-center"> 15 {{ if .State.IsClosed }} 16 <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 </span> 19 {{ else if eq .Kind.String "issues" }} 20 <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 </span> 23 {{ else if .State.IsOpen }} 24 <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 </span> 27 {{ else if .State.IsMerged }} 28 <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 </span> 31 {{ else }} 32 <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 </span> 35 {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 </div> 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 <div>
+1 -1
appview/pages/templates/strings/string.html
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 - <div class="flex gap-2 text-base"> 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true"
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 + <div class="flex gap-2 items-stretch text-base"> 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true"
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+8
appview/pulls/pulls.go
··· 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 return 1368 } 1369 } 1370 1371 if err = tx.Commit(); err != nil { 1372 log.Println("failed to create pull request", err) 1373 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1374 return 1375 } 1376 1377 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
··· 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 return 1368 } 1369 + 1370 } 1371 1372 if err = tx.Commit(); err != nil { 1373 log.Println("failed to create pull request", err) 1374 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1375 return 1376 + } 1377 + 1378 + // notify about each pull 1379 + // 1380 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1381 + for _, p := range stack { 1382 + s.notifier.NewPull(r.Context(), p) 1383 } 1384 1385 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+9 -9
flake.lock
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 - "lastModified": 1765186076, 154 - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 158 "type": "github" 159 }, 160 "original": {
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 "type": "github" 159 }, 160 "original": {
+30 -2
flake.nix
··· 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 92 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 93 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 }); 95 in { 96 overlays.default = final: prev: { 97 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 98 }; 99 100 packages = forAllSystems (system: let ··· 103 staticPackages = mkPackageSet pkgs.pkgsStatic; 104 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 105 in { 106 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 107 108 pkgsStatic-appview = staticPackages.appview; 109 pkgsStatic-knot = staticPackages.knot; ··· 302 imports = [./nix/modules/spindle.nix]; 303 304 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 305 }; 306 }; 307 }
··· 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 92 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 93 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 95 + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 96 + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 97 + tap = self.callPackage ./nix/pkgs/tap.nix {}; 98 }); 99 in { 100 overlays.default = final: prev: { 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview did-method-plc bluesky-jetstream bluesky-relay tap; 102 }; 103 104 packages = forAllSystems (system: let ··· 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 109 in { 110 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib did-method-plc bluesky-jetstream bluesky-relay tap; 111 112 pkgsStatic-appview = staticPackages.appview; 113 pkgsStatic-knot = staticPackages.knot; ··· 306 imports = [./nix/modules/spindle.nix]; 307 308 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 309 + }; 310 + nixosModules.did-method-plc = { 311 + lib, 312 + pkgs, 313 + ... 314 + }: { 315 + imports = [./nix/modules/did-method-plc.nix]; 316 + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 317 + }; 318 + nixosModules.bluesky-relay = { 319 + lib, 320 + pkgs, 321 + ... 322 + }: { 323 + imports = [./nix/modules/bluesky-relay.nix]; 324 + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 325 + }; 326 + nixosModules.bluesky-jetstream = { 327 + lib, 328 + pkgs, 329 + ... 330 + }: { 331 + imports = [./nix/modules/bluesky-jetstream.nix]; 332 + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 333 }; 334 }; 335 }
+2 -1
go.mod
··· 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/yuin/goldmark v1.7.13 48 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 50 golang.org/x/crypto v0.40.0 51 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 52 golang.org/x/image v0.31.0 53 golang.org/x/net v0.42.0 54 - golang.org/x/sync v0.17.0 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 gopkg.in/yaml.v3 v3.0.1 57 ) ··· 203 go.uber.org/atomic v1.11.0 // indirect 204 go.uber.org/multierr v1.11.0 // indirect 205 go.uber.org/zap v1.27.0 // indirect 206 golang.org/x/sys v0.34.0 // indirect 207 golang.org/x/text v0.29.0 // indirect 208 golang.org/x/time v0.12.0 // indirect
··· 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 golang.org/x/crypto v0.40.0 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 golang.org/x/image v0.31.0 54 golang.org/x/net v0.42.0 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 gopkg.in/yaml.v3 v3.0.1 57 ) ··· 203 go.uber.org/atomic v1.11.0 // indirect 204 go.uber.org/multierr v1.11.0 // indirect 205 go.uber.org/zap v1.27.0 // indirect 206 + golang.org/x/sync v0.17.0 // indirect 207 golang.org/x/sys v0.34.0 // indirect 208 golang.org/x/text v0.29.0 // indirect 209 golang.org/x/time v0.12.0 // indirect
+2
go.sum
··· 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 509 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 510 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
··· 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 509 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 510 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 511 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 512 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+3
nix/gomod2nix.toml
··· 530 [mod."github.com/yuin/goldmark"] 531 version = "v1.7.13" 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 [mod."github.com/yuin/goldmark-highlighting/v2"] 534 version = "v2.0.0-20230729083705-37449abec8cc" 535 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
··· 530 [mod."github.com/yuin/goldmark"] 531 version = "v1.7.13" 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 + [mod."github.com/yuin/goldmark-emoji"] 534 + version = "v1.0.6" 535 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 536 [mod."github.com/yuin/goldmark-highlighting/v2"] 537 version = "v2.0.0-20230729083705-37449abec8cc" 538 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+64
nix/modules/bluesky-jetstream.nix
···
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-jetstream; 8 + in 9 + with lib; { 10 + options.services.bluesky-jetstream = { 11 + enable = mkEnableOption "jetstream server"; 12 + package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 + 14 + # dataDir = mkOption { 15 + # type = types.str; 16 + # default = "/var/lib/jetstream"; 17 + # description = "directory to store data (pebbleDB)"; 18 + # }; 19 + livenessTtl = mkOption { 20 + type = types.int; 21 + default = 15; 22 + description = "time to restart when no event detected (seconds)"; 23 + }; 24 + websocketUrl = mkOption { 25 + type = types.str; 26 + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 + }; 29 + }; 30 + config = mkIf cfg.enable { 31 + systemd.services.bluesky-jetstream = { 32 + description = "bluesky jetstream"; 33 + after = ["network.target" "pds.service"]; 34 + wantedBy = ["multi-user.target"]; 35 + 36 + serviceConfig = { 37 + User = "jetstream"; 38 + Group = "jetstream"; 39 + StateDirectory = "jetstream"; 40 + StateDirectoryMode = "0755"; 41 + # preStart = '' 42 + # mkdir -p "${cfg.dataDir}" 43 + # chown -R jetstream:jetstream "${cfg.dataDir}" 44 + # ''; 45 + # WorkingDirectory = cfg.dataDir; 46 + Environment = [ 47 + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 + "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 + ]; 51 + ExecStart = getExe cfg.package; 52 + Restart = "always"; 53 + RestartSec = 5; 54 + }; 55 + }; 56 + users = { 57 + users.jetstream = { 58 + group = "jetstream"; 59 + isSystemUser = true; 60 + }; 61 + groups.jetstream = {}; 62 + }; 63 + }; 64 + }
+48
nix/modules/bluesky-relay.nix
···
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-relay; 8 + in 9 + with lib; { 10 + options.services.bluesky-relay = { 11 + enable = mkEnableOption "relay server"; 12 + package = mkPackageOption pkgs "bluesky-relay" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + systemd.services.bluesky-relay = { 16 + description = "bluesky relay"; 17 + after = ["network.target" "pds.service"]; 18 + wantedBy = ["multi-user.target"]; 19 + 20 + serviceConfig = { 21 + User = "relay"; 22 + Group = "relay"; 23 + StateDirectory = "relay"; 24 + StateDirectoryMode = "0755"; 25 + Environment = [ 26 + "RELAY_ADMIN_PASSWORD=password" 27 + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 + "RELAY_IP_BIND=:2470" 30 + "RELAY_PERSIST_DIR=/var/lib/relay" 31 + "RELAY_DISABLE_REQUEST_CRAWL=0" 32 + "RELAY_INITIAL_SEQ_NUMBER=1" 33 + "RELAY_ALLOW_INSECURE_HOSTS=1" 34 + ]; 35 + ExecStart = "${getExe cfg.package} serve"; 36 + Restart = "always"; 37 + RestartSec = 5; 38 + }; 39 + }; 40 + users = { 41 + users.relay = { 42 + group = "relay"; 43 + isSystemUser = true; 44 + }; 45 + groups.relay = {}; 46 + }; 47 + }; 48 + }
+76
nix/modules/did-method-plc.nix
···
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.did-method-plc; 8 + in 9 + with lib; { 10 + options.services.did-method-plc = { 11 + enable = mkEnableOption "did-method-plc server"; 12 + package = mkPackageOption pkgs "did-method-plc" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + services.postgresql = { 16 + enable = true; 17 + package = pkgs.postgresql_14; 18 + ensureDatabases = ["plc"]; 19 + ensureUsers = [ 20 + { 21 + name = "pg"; 22 + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 + } 24 + ]; 25 + authentication = '' 26 + local all all trust 27 + host all all 127.0.0.1/32 trust 28 + ''; 29 + }; 30 + systemd.services.did-method-plc = { 31 + description = "did-method-plc"; 32 + 33 + after = ["postgresql.service"]; 34 + wants = ["postgresql.service"]; 35 + wantedBy = ["multi-user.target"]; 36 + 37 + environment = let 38 + db_creds_json = builtins.toJSON { 39 + username = "pg"; 40 + password = ""; 41 + host = "127.0.0.1"; 42 + port = 5432; 43 + }; 44 + in { 45 + # TODO: inherit from config 46 + DEBUG_MODE = "1"; 47 + LOG_ENABLED = "true"; 48 + LOG_LEVEL = "debug"; 49 + LOG_DESTINATION = "1"; 50 + ENABLE_MIGRATIONS = "true"; 51 + DB_CREDS_JSON = db_creds_json; 52 + DB_MIGRATE_CREDS_JSON = db_creds_json; 53 + PLC_VERSION = "0.0.1"; 54 + PORT = "8080"; 55 + }; 56 + 57 + serviceConfig = { 58 + ExecStart = getExe cfg.package; 59 + User = "plc"; 60 + Group = "plc"; 61 + StateDirectory = "plc"; 62 + StateDirectoryMode = "0755"; 63 + Restart = "always"; 64 + 65 + # Hardening 66 + }; 67 + }; 68 + users = { 69 + users.plc = { 70 + group = "plc"; 71 + isSystemUser = true; 72 + }; 73 + groups.plc = {}; 74 + }; 75 + }; 76 + }
+20
nix/pkgs/bluesky-jetstream.nix
···
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-jetstream"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "jetstream"; 11 + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 + }; 14 + subPackages = ["cmd/jetstream"]; 15 + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "jetstream"; 19 + }; 20 + }
+20
nix/pkgs/bluesky-relay.nix
···
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-relay"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "boltlessengineer"; 10 + repo = "indigo"; 11 + rev = "b769ea60b7dde5e2bd0b8ee3ce8462a0c0e596fe"; 12 + sha256 = "sha256-jHRY825TBYaH1WkKFUoNbo4UlMSyuHvCGjYPiBnKo44="; 13 + }; 14 + subPackages = ["cmd/relay"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "relay"; 19 + }; 20 + }
+65
nix/pkgs/did-method-plc.nix
···
··· 1 + # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 + { 3 + lib, 4 + stdenv, 5 + fetchFromGitHub, 6 + fetchYarnDeps, 7 + yarnConfigHook, 8 + yarnBuildHook, 9 + nodejs, 10 + makeBinaryWrapper, 11 + }: 12 + stdenv.mkDerivation (finalAttrs: { 13 + pname = "did-method-plc"; 14 + version = "0.0.1"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "did-method-plc"; 18 + repo = "did-method-plc"; 19 + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 + }; 22 + postPatch = '' 23 + # remove dd-trace dependency 24 + sed -i '3d' packages/server/service/index.js 25 + ''; 26 + 27 + yarnOfflineCache = fetchYarnDeps { 28 + yarnLock = finalAttrs.src + "/yarn.lock"; 29 + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 + }; 31 + 32 + nativeBuildInputs = [ 33 + yarnConfigHook 34 + yarnBuildHook 35 + nodejs 36 + makeBinaryWrapper 37 + ]; 38 + yarnBuildScript = "lerna"; 39 + yarnBuildFlags = [ 40 + "run" 41 + "build" 42 + "--scope" 43 + "@did-plc/server" 44 + "--include-dependencies" 45 + ]; 46 + 47 + installPhase = '' 48 + runHook preInstall 49 + 50 + mkdir -p $out/lib/node_modules/ 51 + mv packages/ $out/lib/packages/ 52 + mv node_modules/* $out/lib/node_modules/ 53 + 54 + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 + --add-flags $out/lib/packages/server/service/index.js \ 56 + --add-flags --enable-source-maps \ 57 + --set NODE_PATH $out/lib/node_modules 58 + 59 + runHook postInstall 60 + ''; 61 + 62 + meta = { 63 + mainProgram = "plc"; 64 + }; 65 + })
+20
nix/pkgs/tap.nix
···
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "tap"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "indigo"; 11 + rev = "f92cb29224fcc60f666b20ee3514e431a58ff811"; 12 + sha256 = "sha256-35ltXnq0SJeo3j33D7Nndbcnw5XWBJLRrmZ+nCmZVQw="; 13 + }; 14 + subPackages = ["cmd/tap"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "tap"; 19 + }; 20 + }
+144
rbac2/rbac2.go
···
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + adapter "github.com/Blank-Xu/sql-adapter" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/casbin/casbin/v2" 10 + "github.com/casbin/casbin/v2/model" 11 + "github.com/casbin/casbin/v2/util" 12 + "tangled.org/core/api/tangled" 13 + ) 14 + 15 + const ( 16 + Model = ` 17 + [request_definition] 18 + r = sub, dom, obj, act 19 + 20 + [policy_definition] 21 + p = sub, dom, obj, act 22 + 23 + [role_definition] 24 + g = _, _, _ 25 + 26 + [policy_effect] 27 + e = some(where (p.eft == allow)) 28 + 29 + [matchers] 30 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 31 + ` 32 + ) 33 + 34 + type Enforcer struct { 35 + e *casbin.Enforcer 36 + } 37 + 38 + func NewEnforcer(path string) (*Enforcer, error) { 39 + m, err := model.NewModelFromString(Model) 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 45 + if err != nil { 46 + return nil, err 47 + } 48 + 49 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 50 + if err != nil { 51 + return nil, err 52 + } 53 + 54 + e, err := casbin.NewEnforcer(m, a) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + if err := seedTangledPolicies(e); err != nil { 60 + return nil, err 61 + } 62 + 63 + return &Enforcer{e}, nil 64 + } 65 + 66 + func seedTangledPolicies(e *casbin.Enforcer) error { 67 + // policies 68 + aturi := func(nsid string) string { 69 + return fmt.Sprintf("at://{did}/%s/{rkey}", nsid) 70 + } 71 + 72 + _, err := e.AddPoliciesEx([][]string{ 73 + // sub | dom | obj | act 74 + {"repo:owner", aturi(tangled.RepoNSID), "/", "write"}, 75 + {"repo:owner", aturi(tangled.RepoNSID), "/collaborator", "write"}, // invite 76 + {"repo:collaborator", aturi(tangled.RepoNSID), "/settings", "write"}, 77 + {"repo:collaborator", aturi(tangled.RepoNSID), "/git", "write"}, // git push 78 + 79 + {"server:owner", "/knot/{did}", "/member", "write"}, // invite 80 + {"server:member", "/knot/{did}", "/git", "write"}, 81 + 82 + {"server:owner", "/spindle/{did}", "/member", "write"}, // invite 83 + }) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + // grouping policies 89 + // TODO(boltless): define our own matcher to replace keyMatch4 90 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 91 + _, err = e.AddGroupingPoliciesEx([][]string{ 92 + // sub | role | dom 93 + {"repo:owner", "repo:collaborator", aturi(tangled.RepoNSID)}, 94 + 95 + // using '/knot/' prefix here because knot/spindle identifiers don't 96 + // include the collection type 97 + {"server:owner", "server:member", "/knot/{did}"}, 98 + {"server:owner", "server:member", "/spindle/{did}"}, 99 + }) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 104 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 105 + if err != nil { 106 + return false, err 107 + } 108 + for _, r := range roles { 109 + if r == role { 110 + return true, nil 111 + } 112 + } 113 + return false, nil 114 + } 115 + 116 + // setRoleForUser sets single user role for specified domain. 117 + // All existing users with that role will be removed. 118 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 119 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 120 + if err != nil { 121 + return err 122 + } 123 + 124 + for _, oldUser := range currentUsers { 125 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 126 + if err != nil { 127 + return err 128 + } 129 + } 130 + 131 + _, err = e.e.AddRoleForUser(name, role, domain...) 132 + return err 133 + } 134 + 135 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 136 + func validateAtUri(uri syntax.ATURI, expected string) error { 137 + if !uri.Authority().IsDID() { 138 + return fmt.Errorf("expected at-uri with did") 139 + } 140 + if expected != "" && uri.Collection().String() != expected { 141 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 142 + } 143 + return nil 144 + }
+115
rbac2/rbac2_test.go
···
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + _ "github.com/mattn/go-sqlite3" 8 + "github.com/stretchr/testify/assert" 9 + "tangled.org/core/rbac2" 10 + ) 11 + 12 + func setup(t *testing.T) *rbac2.Enforcer { 13 + enforcer, err := rbac2.NewEnforcer(":memory:") 14 + assert.NoError(t, err) 15 + 16 + return enforcer 17 + } 18 + 19 + func TestRepoOwnerPermissions(t *testing.T) { 20 + var ( 21 + e = setup(t) 22 + ok bool 23 + err error 24 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 25 + fooUser = syntax.DID("did:plc:foo") 26 + ) 27 + 28 + assert.NoError(t, e.AddRepo(fooRepo)) 29 + 30 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 31 + assert.NoError(t, err) 32 + assert.True(t, ok, "repo author should be repo owner") 33 + 34 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 35 + assert.NoError(t, err) 36 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 37 + 38 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 39 + assert.NoError(t, err) 40 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 41 + 42 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 43 + assert.NoError(t, err) 44 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 45 + } 46 + 47 + func TestRepoCollaboratorPermissions(t *testing.T) { 48 + var ( 49 + e = setup(t) 50 + ok bool 51 + err error 52 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 53 + barUser = syntax.DID("did:plc:bar") 54 + ) 55 + 56 + assert.NoError(t, e.AddRepo(fooRepo)) 57 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 58 + 59 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 60 + assert.NoError(t, err) 61 + assert.True(t, ok, "should set repo collaborator") 62 + 63 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 64 + assert.NoError(t, err) 65 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 66 + 67 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 68 + assert.NoError(t, err) 69 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 70 + } 71 + 72 + func TestGetByRole(t *testing.T) { 73 + var ( 74 + e = setup(t) 75 + err error 76 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 77 + owner = syntax.DID("did:plc:foo") 78 + collaborator1 = syntax.DID("did:plc:bar") 79 + collaborator2 = syntax.DID("did:plc:baz") 80 + ) 81 + 82 + assert.NoError(t, e.AddRepo(fooRepo)) 83 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 85 + 86 + collaborators, err := e.GetRepoCollaborators(fooRepo) 87 + assert.NoError(t, err) 88 + assert.ElementsMatch(t, []syntax.DID{ 89 + owner, 90 + collaborator1, 91 + collaborator2, 92 + }, collaborators) 93 + } 94 + 95 + func TestSpindleOwnerPermissions(t *testing.T) { 96 + var ( 97 + e = setup(t) 98 + ok bool 99 + err error 100 + spindle = syntax.DID("did:web:spindle.example.com") 101 + owner = syntax.DID("did:plc:foo") 102 + member = syntax.DID("did:plc:bar") 103 + ) 104 + 105 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 106 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 107 + 108 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 109 + assert.NoError(t, err) 110 + assert.True(t, ok, "spindle owner can invite members") 111 + 112 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 113 + assert.NoError(t, err) 114 + assert.False(t, ok, "spindle member cannot invite members") 115 + }
+91
rbac2/repo.go
···
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "#/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "#/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "#/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "#/git", "write") 91 + }
+29
rbac2/spindle.go
···
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.e.HasRoleForUser(user.String(), "server:member", spindle.String()) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "#/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+1
spindle/db/repos.go
··· 16 if err != nil { 17 return nil, err 18 } 19 20 var knots []string 21 for rows.Next() {
··· 16 if err != nil { 17 return nil, err 18 } 19 + defer rows.Close() 20 21 var knots []string 22 for rows.Next() {
+5 -1
spindle/engine/engine.go
··· 70 } 71 defer eng.DestroyWorkflow(ctx, wid) 72 73 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 74 if err != nil { 75 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 76 wfLogger = nil
··· 70 } 71 defer eng.DestroyWorkflow(ctx, wid) 72 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 78 if err != nil { 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 wfLogger = nil
+6 -1
spindle/models/logger.go
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 } 16 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 path := LogFilePath(baseDir, wid) 19 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 return &WorkflowLogger{ 26 file: file, 27 encoder: json.NewEncoder(file), 28 }, nil 29 } 30 ··· 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 entry := NewDataLogLine(w.idx, line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 + mask *SecretMask 16 } 17 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 19 path := LogFilePath(baseDir, wid) 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 26 return &WorkflowLogger{ 27 file: file, 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 30 }, nil 31 } 32 ··· 64 65 func (w *dataWriter) Write(p []byte) (int, error) { 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 70 entry := NewDataLogLine(w.idx, line, w.stream) 71 if err := w.logger.encoder.Encode(entry); err != nil { 72 return 0, err
+51
spindle/models/secret_mask.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }