From 9bee773dd456b2b83e8148938e34c6b18e4e8c26 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 29 Jul 2025 13:17:05 +0100 Subject: [PATCH] spindle/xrpc: add xrpc implementations for add and remove secret Change-Id: sytllknynxmlxvmnklpvvqrykxlzyyss Signed-off-by: oppiliappan --- spindle/ingester.go | 10 ++- spindle/xrpc/add_secret.go | 89 ++++++++++++++++++++ spindle/xrpc/list_secrets.go | 91 +++++++++++++++++++++ spindle/xrpc/remove_secret.go | 82 +++++++++++++++++++ spindle/xrpc/xrpc.go | 147 ++++++++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 spindle/xrpc/add_secret.go create mode 100644 spindle/xrpc/list_secrets.go create mode 100644 spindle/xrpc/remove_secret.go create mode 100644 spindle/xrpc/xrpc.go diff --git a/spindle/ingester.go b/spindle/ingester.go index f2af146..7a8dfb2 100644 --- a/spindle/ingester.go +++ b/spindle/ingester.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/eventconsumer" + "tangled.sh/tangled.sh/core/rbac" "github.com/bluesky-social/jetstream/pkg/models" ) @@ -72,7 +74,7 @@ func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { return fmt.Errorf("failed to enforce permissions: %w", err) } - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { l.Error("failed to add member", "error", err) return fmt.Errorf("failed to add member: %w", err) } @@ -127,6 +129,12 @@ func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { return fmt.Errorf("failed to add repo: %w", err) } + // add repo to rbac + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { + l.Error("failed to add repo to enforcer", "error", err) + return fmt.Errorf("failed to add repo: %w", err) + } + // add this knot to the event consumer src := eventconsumer.NewKnotSource(record.Knot) s.ks.AddSource(context.Background(), src) diff --git a/spindle/xrpc/add_secret.go b/spindle/xrpc/add_secret.go new file mode 100644 index 0000000..162412c --- /dev/null +++ b/spindle/xrpc/add_secret.go @@ -0,0 +1,89 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/spindle/secrets" +) + +func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(MissingActorDidError) + return + } + + var data tangled.RepoAddSecret_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(GenericError(err)) + return + } + + if err := secrets.ValidateKey(data.Key); err != nil { + fail(GenericError(err)) + return + } + + // unfortunately we have to resolve repo-at here + repoAt, err := syntax.ParseATURI(data.Repo) + if err != nil { + fail(InvalidRepoError(data.Repo)) + return + } + + // resolve this aturi to extract the repo record + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) + return + } + + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) + if err != nil { + fail(GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) + if err != nil { + fail(GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { + l.Error("insufficent permissions", "did", actorDid.String()) + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + secret := secrets.UnlockedSecret{ + Repo: secrets.DidSlashRepo(didPath), + Key: data.Key, + Value: data.Value, + CreatedBy: actorDid, + } + err = x.Vault.AddSecret(secret) + if err != nil { + l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) + writeError(w, GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/spindle/xrpc/list_secrets.go b/spindle/xrpc/list_secrets.go new file mode 100644 index 0000000..3cdc762 --- /dev/null +++ b/spindle/xrpc/list_secrets.go @@ -0,0 +1,91 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/spindle/secrets" +) + +func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(MissingActorDidError) + return + } + + repoParam := r.URL.Query().Get("repo") + if repoParam == "" { + fail(GenericError(fmt.Errorf("empty params"))) + return + } + + // unfortunately we have to resolve repo-at here + repoAt, err := syntax.ParseATURI(repoParam) + if err != nil { + fail(InvalidRepoError(repoParam)) + return + } + + // resolve this aturi to extract the repo record + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) + return + } + + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) + if err != nil { + fail(GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) + if err != nil { + fail(GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { + l.Error("insufficent permissions", "did", actorDid.String()) + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + ls, err := x.Vault.GetSecretsLocked(secrets.DidSlashRepo(didPath)) + if err != nil { + l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) + writeError(w, GenericError(err), http.StatusInternalServerError) + return + } + + var out tangled.RepoListSecrets_Output + for _, l := range ls { + out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{ + Repo: repoAt.String(), + Key: l.Key, + CreatedAt: l.CreatedAt.Format(time.RFC3339), + CreatedBy: l.CreatedBy.String(), + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(out) +} diff --git a/spindle/xrpc/remove_secret.go b/spindle/xrpc/remove_secret.go new file mode 100644 index 0000000..659bc0b --- /dev/null +++ b/spindle/xrpc/remove_secret.go @@ -0,0 +1,82 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/spindle/secrets" +) + +func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(MissingActorDidError) + return + } + + var data tangled.RepoRemoveSecret_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(GenericError(err)) + return + } + + // unfortunately we have to resolve repo-at here + repoAt, err := syntax.ParseATURI(data.Repo) + if err != nil { + fail(InvalidRepoError(data.Repo)) + return + } + + // resolve this aturi to extract the repo record + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) + return + } + + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) + if err != nil { + fail(GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) + if err != nil { + fail(GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { + l.Error("insufficent permissions", "did", actorDid.String()) + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + secret := secrets.Secret[any]{ + Repo: secrets.DidSlashRepo(didPath), + Key: data.Key, + } + err = x.Vault.RemoveSecret(secret) + if err != nil { + l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) + writeError(w, GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go new file mode 100644 index 0000000..f4ab60a --- /dev/null +++ b/spindle/xrpc/xrpc.go @@ -0,0 +1,147 @@ +package xrpc + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/bluesky-social/indigo/atproto/auth" + "github.com/go-chi/chi/v5" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/idresolver" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/spindle/config" + "tangled.sh/tangled.sh/core/spindle/db" + "tangled.sh/tangled.sh/core/spindle/engine" + "tangled.sh/tangled.sh/core/spindle/secrets" +) + +const ActorDid string = "ActorDid" + +type Xrpc struct { + Logger *slog.Logger + Db *db.DB + Enforcer *rbac.Enforcer + Engine *engine.Engine + Config *config.Config + Resolver *idresolver.Resolver + Vault secrets.Manager +} + +func (x *Xrpc) Router() http.Handler { + r := chi.NewRouter() + + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) + r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) + + return r +} + +func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("url", r.URL) + + token := r.Header.Get("Authorization") + token = strings.TrimPrefix(token, "Bearer ") + + s := auth.ServiceAuthValidator{ + Audience: x.Config.Server.Did().String(), + Dir: x.Resolver.Directory(), + } + + did, err := s.Validate(r.Context(), token, nil) + if err != nil { + l.Error("signature verification failed", "err", err) + writeError(w, AuthError(err), http.StatusForbidden) + return + } + + r = r.WithContext( + context.WithValue(r.Context(), ActorDid, did), + ) + + next.ServeHTTP(w, r) + }) +} + +type XrpcError struct { + Tag string `json:"error"` + Message string `json:"message"` +} + +func NewXrpcError(opts ...ErrOpt) XrpcError { + x := XrpcError{} + for _, o := range opts { + o(&x) + } + + return x +} + +type ErrOpt = func(xerr *XrpcError) + +func WithTag(tag string) ErrOpt { + return func(xerr *XrpcError) { + xerr.Tag = tag + } +} + +func WithMessage[S ~string](s S) ErrOpt { + return func(xerr *XrpcError) { + xerr.Message = string(s) + } +} + +func WithError(e error) ErrOpt { + return func(xerr *XrpcError) { + xerr.Message = e.Error() + } +} + +var MissingActorDidError = NewXrpcError( + WithTag("MissingActorDid"), + WithMessage("actor DID not supplied"), +) + +var AuthError = func(err error) XrpcError { + return NewXrpcError( + WithTag("Auth"), + WithError(fmt.Errorf("signature verification failed: %w", err)), + ) +} + +var InvalidRepoError = func(r string) XrpcError { + return NewXrpcError( + WithTag("InvalidRepo"), + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), + ) +} + +func GenericError(err error) XrpcError { + return NewXrpcError( + WithTag("Generic"), + WithError(err), + ) +} + +var AccessControlError = func(d string) XrpcError { + return NewXrpcError( + WithTag("AccessControl"), + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), + ) +} + +// this is slightly different from http_util::write_error to follow the spec: +// +// the json object returned must include an "error" and a "message" +func writeError(w http.ResponseWriter, e XrpcError, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(e) +} -- 2.43.0