Signed-off-by: oppiliappan me@oppi.li
+9
-1
spindle/ingester.go
+9
-1
spindle/ingester.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
+
"path/filepath"
7
8
8
9
"tangled.sh/tangled.sh/core/api/tangled"
9
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/rbac"
10
12
11
13
"github.com/bluesky-social/jetstream/pkg/models"
12
14
)
···
72
74
return fmt.Errorf("failed to enforce permissions: %w", err)
73
75
}
74
76
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
77
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
76
78
l.Error("failed to add member", "error", err)
77
79
return fmt.Errorf("failed to add member: %w", err)
78
80
}
···
127
129
return fmt.Errorf("failed to add repo: %w", err)
128
130
}
129
131
132
+
// add repo to rbac
133
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil {
134
+
l.Error("failed to add repo to enforcer", "error", err)
135
+
return fmt.Errorf("failed to add repo: %w", err)
136
+
}
137
+
130
138
// add this knot to the event consumer
131
139
src := eventconsumer.NewKnotSource(record.Knot)
132
140
s.ks.AddSource(context.Background(), src)
+89
spindle/xrpc/add_secret.go
+89
spindle/xrpc/add_secret.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/secrets"
15
+
)
16
+
17
+
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
18
+
l := x.Logger
19
+
fail := func(e XrpcError) {
20
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
21
+
writeError(w, e, http.StatusBadRequest)
22
+
}
23
+
24
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
25
+
if !ok {
26
+
fail(MissingActorDidError)
27
+
return
28
+
}
29
+
30
+
var data tangled.RepoAddSecret_Input
31
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
+
fail(GenericError(err))
33
+
return
34
+
}
35
+
36
+
if err := secrets.ValidateKey(data.Key); err != nil {
37
+
fail(GenericError(err))
38
+
return
39
+
}
40
+
41
+
// unfortunately we have to resolve repo-at here
42
+
repoAt, err := syntax.ParseATURI(data.Repo)
43
+
if err != nil {
44
+
fail(InvalidRepoError(data.Repo))
45
+
return
46
+
}
47
+
48
+
// resolve this aturi to extract the repo record
49
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
50
+
if err != nil || ident.Handle.IsInvalidHandle() {
51
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
52
+
return
53
+
}
54
+
55
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
56
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
57
+
if err != nil {
58
+
fail(GenericError(err))
59
+
return
60
+
}
61
+
62
+
repo := resp.Value.Val.(*tangled.Repo)
63
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
64
+
if err != nil {
65
+
fail(GenericError(err))
66
+
return
67
+
}
68
+
69
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
70
+
l.Error("insufficent permissions", "did", actorDid.String())
71
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
72
+
return
73
+
}
74
+
75
+
secret := secrets.UnlockedSecret{
76
+
Repo: secrets.DidSlashRepo(didPath),
77
+
Key: data.Key,
78
+
Value: data.Value,
79
+
CreatedBy: actorDid,
80
+
}
81
+
err = x.Vault.AddSecret(secret)
82
+
if err != nil {
83
+
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
84
+
writeError(w, GenericError(err), http.StatusInternalServerError)
85
+
return
86
+
}
87
+
88
+
w.WriteHeader(http.StatusOK)
89
+
}
+91
spindle/xrpc/list_secrets.go
+91
spindle/xrpc/list_secrets.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
)
17
+
18
+
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger
20
+
fail := func(e XrpcError) {
21
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
+
writeError(w, e, http.StatusBadRequest)
23
+
}
24
+
25
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
+
if !ok {
27
+
fail(MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
repoParam := r.URL.Query().Get("repo")
32
+
if repoParam == "" {
33
+
fail(GenericError(fmt.Errorf("empty params")))
34
+
return
35
+
}
36
+
37
+
// unfortunately we have to resolve repo-at here
38
+
repoAt, err := syntax.ParseATURI(repoParam)
39
+
if err != nil {
40
+
fail(InvalidRepoError(repoParam))
41
+
return
42
+
}
43
+
44
+
// resolve this aturi to extract the repo record
45
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
+
if err != nil || ident.Handle.IsInvalidHandle() {
47
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
+
return
49
+
}
50
+
51
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
+
if err != nil {
54
+
fail(GenericError(err))
55
+
return
56
+
}
57
+
58
+
repo := resp.Value.Val.(*tangled.Repo)
59
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
+
if err != nil {
61
+
fail(GenericError(err))
62
+
return
63
+
}
64
+
65
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
+
l.Error("insufficent permissions", "did", actorDid.String())
67
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
+
return
69
+
}
70
+
71
+
ls, err := x.Vault.GetSecretsLocked(secrets.DidSlashRepo(didPath))
72
+
if err != nil {
73
+
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
74
+
writeError(w, GenericError(err), http.StatusInternalServerError)
75
+
return
76
+
}
77
+
78
+
var out tangled.RepoListSecrets_Output
79
+
for _, l := range ls {
80
+
out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{
81
+
Repo: repoAt.String(),
82
+
Key: l.Key,
83
+
CreatedAt: l.CreatedAt.Format(time.RFC3339),
84
+
CreatedBy: l.CreatedBy.String(),
85
+
})
86
+
}
87
+
88
+
w.Header().Set("Content-Type", "application/json")
89
+
w.WriteHeader(http.StatusOK)
90
+
json.NewEncoder(w).Encode(out)
91
+
}
+82
spindle/xrpc/remove_secret.go
+82
spindle/xrpc/remove_secret.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/secrets"
15
+
)
16
+
17
+
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
18
+
l := x.Logger
19
+
fail := func(e XrpcError) {
20
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
21
+
writeError(w, e, http.StatusBadRequest)
22
+
}
23
+
24
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
25
+
if !ok {
26
+
fail(MissingActorDidError)
27
+
return
28
+
}
29
+
30
+
var data tangled.RepoRemoveSecret_Input
31
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
+
fail(GenericError(err))
33
+
return
34
+
}
35
+
36
+
// unfortunately we have to resolve repo-at here
37
+
repoAt, err := syntax.ParseATURI(data.Repo)
38
+
if err != nil {
39
+
fail(InvalidRepoError(data.Repo))
40
+
return
41
+
}
42
+
43
+
// resolve this aturi to extract the repo record
44
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
45
+
if err != nil || ident.Handle.IsInvalidHandle() {
46
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
return
48
+
}
49
+
50
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
51
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
52
+
if err != nil {
53
+
fail(GenericError(err))
54
+
return
55
+
}
56
+
57
+
repo := resp.Value.Val.(*tangled.Repo)
58
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
59
+
if err != nil {
60
+
fail(GenericError(err))
61
+
return
62
+
}
63
+
64
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
65
+
l.Error("insufficent permissions", "did", actorDid.String())
66
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
return
68
+
}
69
+
70
+
secret := secrets.Secret[any]{
71
+
Repo: secrets.DidSlashRepo(didPath),
72
+
Key: data.Key,
73
+
}
74
+
err = x.Vault.RemoveSecret(secret)
75
+
if err != nil {
76
+
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
77
+
writeError(w, GenericError(err), http.StatusInternalServerError)
78
+
return
79
+
}
80
+
81
+
w.WriteHeader(http.StatusOK)
82
+
}
+147
spindle/xrpc/xrpc.go
+147
spindle/xrpc/xrpc.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
_ "embed"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log/slog"
9
+
"net/http"
10
+
"strings"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/auth"
13
+
"github.com/go-chi/chi/v5"
14
+
15
+
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/idresolver"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/spindle/config"
19
+
"tangled.sh/tangled.sh/core/spindle/db"
20
+
"tangled.sh/tangled.sh/core/spindle/engine"
21
+
"tangled.sh/tangled.sh/core/spindle/secrets"
22
+
)
23
+
24
+
const ActorDid string = "ActorDid"
25
+
26
+
type Xrpc struct {
27
+
Logger *slog.Logger
28
+
Db *db.DB
29
+
Enforcer *rbac.Enforcer
30
+
Engine *engine.Engine
31
+
Config *config.Config
32
+
Resolver *idresolver.Resolver
33
+
Vault secrets.Manager
34
+
}
35
+
36
+
func (x *Xrpc) Router() http.Handler {
37
+
r := chi.NewRouter()
38
+
39
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
40
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
41
+
r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
42
+
43
+
return r
44
+
}
45
+
46
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
47
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+
l := x.Logger.With("url", r.URL)
49
+
50
+
token := r.Header.Get("Authorization")
51
+
token = strings.TrimPrefix(token, "Bearer ")
52
+
53
+
s := auth.ServiceAuthValidator{
54
+
Audience: x.Config.Server.Did().String(),
55
+
Dir: x.Resolver.Directory(),
56
+
}
57
+
58
+
did, err := s.Validate(r.Context(), token, nil)
59
+
if err != nil {
60
+
l.Error("signature verification failed", "err", err)
61
+
writeError(w, AuthError(err), http.StatusForbidden)
62
+
return
63
+
}
64
+
65
+
r = r.WithContext(
66
+
context.WithValue(r.Context(), ActorDid, did),
67
+
)
68
+
69
+
next.ServeHTTP(w, r)
70
+
})
71
+
}
72
+
73
+
type XrpcError struct {
74
+
Tag string `json:"error"`
75
+
Message string `json:"message"`
76
+
}
77
+
78
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
79
+
x := XrpcError{}
80
+
for _, o := range opts {
81
+
o(&x)
82
+
}
83
+
84
+
return x
85
+
}
86
+
87
+
type ErrOpt = func(xerr *XrpcError)
88
+
89
+
func WithTag(tag string) ErrOpt {
90
+
return func(xerr *XrpcError) {
91
+
xerr.Tag = tag
92
+
}
93
+
}
94
+
95
+
func WithMessage[S ~string](s S) ErrOpt {
96
+
return func(xerr *XrpcError) {
97
+
xerr.Message = string(s)
98
+
}
99
+
}
100
+
101
+
func WithError(e error) ErrOpt {
102
+
return func(xerr *XrpcError) {
103
+
xerr.Message = e.Error()
104
+
}
105
+
}
106
+
107
+
var MissingActorDidError = NewXrpcError(
108
+
WithTag("MissingActorDid"),
109
+
WithMessage("actor DID not supplied"),
110
+
)
111
+
112
+
var AuthError = func(err error) XrpcError {
113
+
return NewXrpcError(
114
+
WithTag("Auth"),
115
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
116
+
)
117
+
}
118
+
119
+
var InvalidRepoError = func(r string) XrpcError {
120
+
return NewXrpcError(
121
+
WithTag("InvalidRepo"),
122
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
123
+
)
124
+
}
125
+
126
+
func GenericError(err error) XrpcError {
127
+
return NewXrpcError(
128
+
WithTag("Generic"),
129
+
WithError(err),
130
+
)
131
+
}
132
+
133
+
var AccessControlError = func(d string) XrpcError {
134
+
return NewXrpcError(
135
+
WithTag("AccessControl"),
136
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
137
+
)
138
+
}
139
+
140
+
// this is slightly different from http_util::write_error to follow the spec:
141
+
//
142
+
// the json object returned must include an "error" and a "message"
143
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
144
+
w.Header().Set("Content-Type", "application/json")
145
+
w.WriteHeader(status)
146
+
json.NewEncoder(w).Encode(e)
147
+
}