diff --git a/spindle/server.go b/spindle/server.go index f04b2068..fec4f34f 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -24,6 +24,7 @@ import ( "tangled.sh/tangled.sh/core/spindle/queue" "tangled.sh/tangled.sh/core/spindle/secrets" "tangled.sh/tangled.sh/core/spindle/xrpc" + "tangled.sh/tangled.sh/core/xrpc/serviceauth" ) //go:embed motd @@ -212,14 +213,17 @@ func (s *Spindle) Router() http.Handler { func (s *Spindle) XrpcRouter() http.Handler { logger := s.l.With("route", "xrpc") + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) + x := xrpc.Xrpc{ - Logger: logger, - Db: s.db, - Enforcer: s.e, - Engine: s.eng, - Config: s.cfg, - Resolver: s.res, - Vault: s.vault, + Logger: logger, + Db: s.db, + Enforcer: s.e, + Engine: s.eng, + Config: s.cfg, + Resolver: s.res, + Vault: s.vault, + ServiceAuth: serviceAuth, } return x.Router() diff --git a/spindle/xrpc/add_secret.go b/spindle/xrpc/add_secret.go index 5868fe40..a196e14e 100644 --- a/spindle/xrpc/add_secret.go +++ b/spindle/xrpc/add_secret.go @@ -13,63 +13,64 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/rbac" "tangled.sh/tangled.sh/core/spindle/secrets" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" ) func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { l := x.Logger - fail := func(e XrpcError) { + fail := func(e xrpcerr.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) + fail(xrpcerr.MissingActorDidError) return } var data tangled.RepoAddSecret_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(GenericError(err)) + fail(xrpcerr.GenericError(err)) return } if err := secrets.ValidateKey(data.Key); err != nil { - fail(GenericError(err)) + fail(xrpcerr.GenericError(err)) return } // unfortunately we have to resolve repo-at here repoAt, err := syntax.ParseATURI(data.Repo) if err != nil { - fail(InvalidRepoError(data.Repo)) + fail(xrpcerr.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))) + fail(xrpcerr.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)) + fail(xrpcerr.GenericError(err)) return } repo := resp.Value.Val.(*tangled.Repo) didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) if err != nil { - fail(GenericError(err)) + fail(xrpcerr.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) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) return } @@ -83,7 +84,7 @@ func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { err = x.Vault.AddSecret(r.Context(), secret) if err != nil { l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) - writeError(w, GenericError(err), http.StatusInternalServerError) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) return } diff --git a/spindle/xrpc/list_secrets.go b/spindle/xrpc/list_secrets.go index 1e84de7b..535284fa 100644 --- a/spindle/xrpc/list_secrets.go +++ b/spindle/xrpc/list_secrets.go @@ -13,65 +13,66 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/rbac" "tangled.sh/tangled.sh/core/spindle/secrets" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" ) func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { l := x.Logger - fail := func(e XrpcError) { + fail := func(e xrpcerr.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) + fail(xrpcerr.MissingActorDidError) return } repoParam := r.URL.Query().Get("repo") if repoParam == "" { - fail(GenericError(fmt.Errorf("empty params"))) + fail(xrpcerr.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)) + fail(xrpcerr.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))) + fail(xrpcerr.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)) + fail(xrpcerr.GenericError(err)) return } repo := resp.Value.Val.(*tangled.Repo) didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) if err != nil { - fail(GenericError(err)) + fail(xrpcerr.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) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) return } ls, err := x.Vault.GetSecretsLocked(r.Context(), 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) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) return } diff --git a/spindle/xrpc/remove_secret.go b/spindle/xrpc/remove_secret.go index a7fb7e08..1f6b6aca 100644 --- a/spindle/xrpc/remove_secret.go +++ b/spindle/xrpc/remove_secret.go @@ -12,58 +12,59 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/rbac" "tangled.sh/tangled.sh/core/spindle/secrets" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" ) func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { l := x.Logger - fail := func(e XrpcError) { + fail := func(e xrpcerr.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) + fail(xrpcerr.MissingActorDidError) return } var data tangled.RepoRemoveSecret_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(GenericError(err)) + fail(xrpcerr.GenericError(err)) return } // unfortunately we have to resolve repo-at here repoAt, err := syntax.ParseATURI(data.Repo) if err != nil { - fail(InvalidRepoError(data.Repo)) + fail(xrpcerr.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))) + fail(xrpcerr.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)) + fail(xrpcerr.GenericError(err)) return } repo := resp.Value.Val.(*tangled.Repo) didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) if err != nil { - fail(GenericError(err)) + fail(xrpcerr.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) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) return } @@ -74,7 +75,7 @@ func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { err = x.Vault.RemoveSecret(r.Context(), secret) if err != nil { l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) - writeError(w, GenericError(err), http.StatusInternalServerError) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) return } diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index f4ab60aa..956857fa 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -1,15 +1,11 @@ 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" @@ -19,128 +15,37 @@ import ( "tangled.sh/tangled.sh/core/spindle/db" "tangled.sh/tangled.sh/core/spindle/engine" "tangled.sh/tangled.sh/core/spindle/secrets" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" + "tangled.sh/tangled.sh/core/xrpc/serviceauth" ) 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 + Logger *slog.Logger + Db *db.DB + Enforcer *rbac.Enforcer + Engine *engine.Engine + Config *config.Config + Resolver *idresolver.Resolver + Vault secrets.Manager + ServiceAuth *serviceauth.ServiceAuth } 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) + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) + r.With(x.ServiceAuth.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) { +func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(e)