spindle/xrpc: add xrpc implementations for add and remove secret #375

merged
opened by oppi.li targeting master from push-vynsusnqpmus
+9 -1
spindle/ingester.go
···
"context"
"encoding/json"
"fmt"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/eventconsumer"
"github.com/bluesky-social/jetstream/pkg/models"
)
···
return fmt.Errorf("failed to enforce permissions: %w", err)
}
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
l.Error("failed to add member", "error", err)
return fmt.Errorf("failed to add member: %w", 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)
···
"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"
)
···
return fmt.Errorf("failed to enforce permissions: %w", err)
}
+
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)
}
···
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)
+89
spindle/xrpc/add_secret.go
···
···
+
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)
+
}
+91
spindle/xrpc/list_secrets.go
···
···
+
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)
+
}
+82
spindle/xrpc/remove_secret.go
···
···
+
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)
+
}
+147
spindle/xrpc/xrpc.go
···
···
+
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)
+
}