forked from tangled.org/core
this repo has no description

appview: implement new ownership process for knots

This is now identical to how we verify spindle registrations, and gets
rid of the registration key. This code is now deduplicated in the
serververify package (previously spindleverify).

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

Changed files
+603 -349
appview
knots
serververify
spindles
spindleverify
state
rbac
+3 -3
appview/ingester.go
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/spindleverify"
+
"tangled.sh/tangled.sh/core/appview/serververify"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
)
···
return err
}
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
+
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
if err != nil {
l.Error("failed to add spindle to db", "err", err, "instance", instance)
return err
}
-
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
+
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
if err != nil {
return fmt.Errorf("failed to mark verified: %w", err)
}
+420 -217
appview/knots/knots.go
···
package knots
import (
-
"context"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
+
"errors"
"fmt"
"log/slog"
"net/http"
-
"strings"
+
"slices"
"time"
"github.com/go-chi/chi/v5"
···
"tangled.sh/tangled.sh/core/appview/middleware"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/serververify"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
Knotstream *eventconsumer.Consumer
}
-
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
+
func (k *Knots) Router() http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.AuthMiddleware(k.OAuth))
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
-
r.Get("/", k.index)
-
r.Post("/key", k.generateKey)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
-
r.Route("/{domain}", func(r chi.Router) {
-
r.Post("/init", k.init)
-
r.Get("/", k.dashboard)
-
r.Route("/member", func(r chi.Router) {
-
r.Use(mw.KnotOwner())
-
r.Get("/", k.members)
-
r.Put("/", k.addMember)
-
r.Delete("/", k.removeMember)
-
})
-
})
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
return r
}
-
// get knots registered by this user
-
func (k *Knots) index(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "index")
-
+
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
if err != nil {
-
l.Error("failed to get registrations by did", "err", err)
+
k.Logger.Error("failed to fetch knot registrations", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
}
k.Pages.Knots(w, pages.KnotsParams{
···
})
}
-
// requires auth
-
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "generateKey")
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
+
l := k.Logger.With("handler", "dashboard")
user := k.OAuth.GetUser(r)
-
did := user.Did
-
l = l.With("did", did)
+
l = l.With("user", user.Did)
-
// check if domain is valid url, and strip extra bits down to just host
-
domain := r.FormValue("domain")
+
domain := chi.URLParam(r, "domain")
if domain == "" {
-
l.Error("empty domain")
-
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
l = l.With("domain", domain)
-
noticeId := "registration-error"
-
fail := func() {
-
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
l.Error("failed to get registrations", "err", err)
+
http.Error(w, "Not found", http.StatusNotFound)
+
return
}
-
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
+
// Find the specific registration for this domain
+
var registration *db.Registration
+
for _, reg := range registrations {
+
if reg.Domain == domain && reg.ByDid == user.Did && reg.Registered != nil {
+
registration = &reg
+
break
+
}
+
}
+
+
if registration == nil {
+
l.Error("registration not found or not verified")
+
http.Error(w, "Not found", http.StatusNotFound)
+
return
+
}
+
registration := registrations[0]
+
+
members, err := k.Enforcer.GetUserByRole("server:member", domain)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
+
l.Error("failed to get knot members", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
+
slices.Sort(members)
-
allRegs, err := db.RegistrationsByDid(k.Db, did)
+
repos, err := db.GetRepos(
+
k.Db,
+
0,
+
db.FilterEq("knot", domain),
+
)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
+
l.Error("failed to get knot repos", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
-
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
-
Registrations: allRegs,
-
})
-
k.Pages.KnotSecret(w, pages.KnotSecretParams{
-
Secret: key,
+
// organize repos by did
+
repoMap := make(map[string][]db.Repo)
+
for _, r := range repos {
+
repoMap[r.Did] = append(repoMap[r.Did], r)
+
}
+
+
k.Pages.Knot(w, pages.KnotParams{
+
LoggedInUser: user,
+
Registration: &registration,
+
Members: members,
+
Repos: repoMap,
+
IsOwner: true,
})
}
-
// create a signed request and check if a node responds to that
-
func (k *Knots) init(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "init")
+
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "register")
-
noticeId := "operation-error"
-
defaultErr := "Failed to initialize knot. Try again later."
+
noticeId := "register-error"
+
defaultErr := "Failed to register knot. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
-
domain := chi.URLParam(r, "domain")
+
domain := r.FormValue("domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
k.Pages.Notice(w, noticeId, "Incomplete form.")
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
l.Info("checking domain")
+
tx, err := k.Db.Begin()
+
if err != nil {
+
l.Error("failed to start transaction", "err", err)
+
fail()
+
return
+
}
+
defer func() {
+
tx.Rollback()
+
k.Enforcer.E.LoadPolicy()
+
}()
-
registration, err := db.RegistrationByDomain(k.Db, domain)
+
err = db.AddKnot(tx, domain, user.Did)
if err != nil {
-
l.Error("failed to get registration for domain", "err", err)
+
l.Error("failed to insert", "err", err)
fail()
return
}
-
if registration.ByDid != user.Did {
-
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
-
w.WriteHeader(http.StatusUnauthorized)
+
+
err = k.Enforcer.AddKnot(domain)
+
if err != nil {
+
l.Error("failed to create knot", "err", err)
+
fail()
return
}
-
secret, err := db.GetRegistrationKey(k.Db, domain)
+
// create record on pds
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to get registration key for domain", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
var exCid *string
+
if ex != nil {
+
exCid = ex.Cid
+
}
+
+
// re-announce by registering under same rkey
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Knot{
+
CreatedAt: time.Now().Format(time.RFC3339),
+
},
+
},
+
SwapRecord: exCid,
+
})
+
if err != nil {
-
l.Error("failed to create knotclient", "err", err)
+
l.Error("failed to put record", "err", err)
fail()
return
}
-
resp, err := client.Init(user.Did)
+
err = tx.Commit()
if err != nil {
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error()))
-
l.Error("failed to make init request", "err", err)
+
l.Error("failed to commit transaction", "err", err)
+
fail()
return
}
-
if resp.StatusCode == http.StatusConflict {
-
k.Pages.Notice(w, noticeId, "This knot is already registered")
-
l.Error("knot already registered", "statuscode", resp.StatusCode)
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
if resp.StatusCode != http.StatusNoContent {
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent))
-
l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent)
+
// begin verification
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
+
if err != nil {
+
l.Error("verification failed", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
// verify response mac
-
signature := resp.Header.Get("X-Signature")
-
signatureBytes, err := hex.DecodeString(signature)
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
if err != nil {
+
l.Error("failed to mark verified", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
expectedMac := hmac.New(sha256.New, []byte(secret))
-
expectedMac.Write([]byte("ok"))
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
-
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
-
k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
-
l.Error("signature mismatch", "bytes", signatureBytes)
+
// ok
+
k.Pages.HxRefresh(w)
+
}
+
+
func (k *Knots) delete(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "delete")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to delete knot. Try again later."
+
fail := func() {
+
k.Pages.Notice(w, noticeId, defaultErr)
+
}
+
+
domain := chi.URLParam(r, "domain")
+
if domain == "" {
+
l.Error("empty domain")
+
fail()
+
return
+
}
+
+
// get record from db first
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
l.Error("failed to get registration", "err", err)
+
fail()
return
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
+
return
+
}
+
registration := registrations[0]
-
tx, err := k.Db.BeginTx(r.Context(), nil)
+
tx, err := k.Db.Begin()
if err != nil {
-
l.Error("failed to start tx", "err", err)
+
l.Error("failed to start txn", "err", err)
fail()
return
}
defer func() {
tx.Rollback()
-
err = k.Enforcer.E.LoadPolicy()
-
if err != nil {
-
l.Error("rollback failed", "err", err)
-
}
+
k.Enforcer.E.LoadPolicy()
}()
-
// mark as registered
-
err = db.Register(tx, domain)
+
err = db.DeleteKnot(
+
tx,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
l.Error("failed to register domain", "err", err)
+
l.Error("failed to delete registration", "err", err)
fail()
return
}
-
// set permissions for this did as owner
-
reg, err := db.RegistrationByDomain(tx, domain)
-
if err != nil {
-
l.Error("failed get registration by domain", "err", err)
-
fail()
-
return
+
// delete from enforcer if it was registered
+
if registration.Registered != nil {
+
err = k.Enforcer.RemoveKnot(domain)
+
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
fail()
+
return
+
}
}
-
// add basic acls for this domain
-
err = k.Enforcer.AddKnot(domain)
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to add knot to enforcer", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
// add this did as owner of this domain
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
})
if err != nil {
-
l.Error("failed to add knot owner to enforcer", "err", err)
-
fail()
-
return
+
// non-fatal
+
l.Error("failed to delete record", "err", err)
}
err = tx.Commit()
if err != nil {
-
l.Error("failed to commit changes", "err", err)
+
l.Error("failed to delete knot", "err", err)
fail()
return
}
err = k.Enforcer.E.SavePolicy()
if err != nil {
-
l.Error("failed to update ACLs", "err", err)
-
fail()
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
// add this knot to knotstream
-
go k.Knotstream.AddSource(
-
context.Background(),
-
eventconsumer.NewKnotSource(domain),
-
)
+
shouldRedirect := r.Header.Get("shouldRedirect")
+
if shouldRedirect == "true" {
+
k.Pages.HxRedirect(w, "/knots")
+
return
+
}
-
k.Pages.KnotListing(w, pages.KnotListingParams{
-
Registration: *reg,
-
})
+
w.Write([]byte{})
}
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "dashboard")
+
func (k *Knots) retry(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "retry")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to verify knot. Try again later."
fail := func() {
-
w.WriteHeader(http.StatusInternalServerError)
+
k.Pages.Notice(w, noticeId, defaultErr)
}
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
l.Error("empty domain")
+
fail()
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
user := k.OAuth.GetUser(r)
-
l = l.With("did", user.Did)
-
-
// dashboard is only available to owners
-
ok, err := k.Enforcer.IsKnotOwner(user.Did, domain)
+
// get record from db first
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
l.Error("failed to query enforcer", "err", err)
+
l.Error("failed to get registration", "err", err)
fail()
+
return
}
-
if !ok {
-
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
return
}
+
registration := registrations[0]
-
reg, err := db.RegistrationByDomain(k.Db, domain)
+
// begin verification
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
+
l.Error("verification failed", "err", err)
+
+
if errors.Is(err, serververify.FetchError) {
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
+
return
+
}
+
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
+
k.Pages.Notice(w, noticeId, e.Error())
+
return
+
}
+
fail()
return
}
-
var members []string
-
if reg.Registered != nil {
-
members, err = k.Enforcer.GetUserByRole("server:member", domain)
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
+
if err != nil {
+
l.Error("failed to mark verified", "err", err)
+
k.Pages.Notice(w, noticeId, err.Error())
+
return
+
}
+
+
// if this knot was previously read-only, then emit a record too
+
//
+
// this is part of migrating from the old knot system to the new one
+
if registration.ReadOnly {
+
// re-announce by registering under same rkey
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to get members list", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
+
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
var exCid *string
+
if ex != nil {
+
exCid = ex.Cid
+
}
+
+
// ignore the error here
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Knot{
+
CreatedAt: time.Now().Format(time.RFC3339),
+
},
+
},
+
SwapRecord: exCid,
+
})
+
if err != nil {
+
l.Error("non-fatal: failed to reannouce knot", "err", err)
+
}
}
-
repos, err := db.GetRepos(
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
+
+
shouldRefresh := r.Header.Get("shouldRefresh")
+
if shouldRefresh == "true" {
+
k.Pages.HxRefresh(w)
+
return
+
}
+
+
// Get updated registration to show
+
registrations, err = db.GetRegistrations(
k.Db,
-
0,
-
db.FilterEq("knot", domain),
-
db.FilterIn("did", members),
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
)
if err != nil {
-
l.Error("failed to get repos list", "err", err)
+
l.Error("failed to get registration", "err", err)
fail()
return
}
-
// convert to map
-
repoByMember := make(map[string][]db.Repo)
-
for _, r := range repos {
-
repoByMember[r.Did] = append(repoByMember[r.Did], r)
-
}
-
-
k.Pages.Knot(w, pages.KnotParams{
-
LoggedInUser: user,
-
Registration: reg,
-
Members: members,
-
Repos: repoByMember,
-
IsOwner: true,
-
})
-
}
-
-
// list members of domain, requires auth and requires owner status
-
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "members")
-
-
domain := chi.URLParam(r, "domain")
-
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
return
}
-
l = l.With("domain", domain)
+
updatedRegistration := registrations[0]
-
// list all members for this domain
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
-
if err != nil {
-
w.Write([]byte("failed to fetch member list"))
-
return
-
}
+
log.Println(updatedRegistration)
-
w.Write([]byte(strings.Join(memberDids, "\n")))
+
w.Header().Set("HX-Reswap", "outerHTML")
+
k.Pages.KnotListing(w, pages.KnotListingParams{
+
Registration: &updatedRegistration,
+
})
}
-
// add member to domain, requires auth and requires invite access
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "members")
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "addMember")
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
l.Error("empty domain")
+
http.Error(w, "Not found", http.StatusNotFound)
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
reg, err := db.RegistrationByDomain(k.Db, domain)
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
l.Error("failed to retrieve domain registration", "err", err)
+
http.Error(w, "Not found", http.StatusNotFound)
return
}
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
-
l = l.With("notice-id", noticeId)
+
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
defaultErr := "Failed to add member. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
-
subjectIdentifier := r.FormValue("subject")
-
if subjectIdentifier == "" {
-
http.Error(w, "malformed form", http.StatusBadRequest)
+
member := r.FormValue("member")
+
if member == "" {
+
l.Error("empty member")
+
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
return
}
-
l = l.With("subjectIdentifier", subjectIdentifier)
+
l = l.With("member", member)
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
-
l.Error("failed to resolve identity", "err", err)
+
l.Error("failed to resolve member identity to handle", "err", err)
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
return
}
-
l = l.With("subjectDid", subjectIdentity.DID)
-
-
l.Info("adding member to knot")
+
if memberId.Handle.IsInvalidHandle() {
+
l.Error("failed to resolve member identity to handle")
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
return
+
}
-
// announce this relation into the firehose, store into owners' pds
+
// write to pds
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to create client", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
currentUser := k.OAuth.GetUser(r)
-
createdAt := time.Now().Format(time.RFC3339)
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
rkey := tid.TID()
+
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
-
Repo: currentUser.Did,
-
Rkey: tid.TID(),
+
Repo: user.Did,
+
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
-
Subject: subjectIdentity.DID.String(),
+
CreatedAt: time.Now().Format(time.RFC3339),
Domain: domain,
-
CreatedAt: createdAt,
-
}},
+
Subject: memberId.DID.String(),
+
},
+
},
})
-
// invalid record
if err != nil {
-
l.Error("failed to write to PDS", "err", err)
+
l.Error("failed to add record to PDS", "err", err)
+
k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
+
return
+
}
+
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
+
if err != nil {
+
l.Error("failed to add member to ACLs", "err", err)
fail()
return
}
-
l = l.With("at-uri", resp.Uri)
-
l.Info("wrote record to PDS")
-
secret, err := db.GetRegistrationKey(k.Db, domain)
+
err = k.Enforcer.E.SavePolicy()
if err != nil {
-
l.Error("failed to get registration key", "err", err)
+
l.Error("failed to save ACL policy", "err", err)
+
fail()
+
return
+
}
+
+
// success
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
+
}
+
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "removeMember")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to remove member. Try again later."
+
fail := func() {
+
k.Pages.Notice(w, noticeId, defaultErr)
+
}
+
+
domain := chi.URLParam(r, "domain")
+
if domain == "" {
+
l.Error("empty domain")
fail()
return
}
+
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
-
l.Error("failed to create client", "err", err)
-
fail()
+
l.Error("failed to get registration", "err", err)
+
return
+
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
return
}
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
+
member := r.FormValue("member")
+
if member == "" {
+
l.Error("empty member")
+
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+
return
+
}
+
l = l.With("member", member)
+
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
-
l.Error("failed to reach knotserver", "err", err)
-
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
+
l.Error("failed to resolve member identity to handle", "err", err)
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
+
return
+
}
+
if memberId.Handle.IsInvalidHandle() {
+
l.Error("failed to resolve member identity to handle")
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
return
}
-
if ksResp.StatusCode != http.StatusNoContent {
-
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
+
// remove from enforcer
+
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+
if err != nil {
+
l.Error("failed to update ACLs", "err", err)
+
fail()
return
}
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to add member to enforcer", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
// success
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
-
}
+
// TODO: We need to track the rkey for knot members to delete the record
+
// For now, just remove from ACLs
+
_ = client
+
+
// commit everything
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to save ACLs", "err", err)
+
fail()
+
return
+
}
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+
// ok
+
k.Pages.HxRefresh(w)
}
+164
appview/serververify/verify.go
···
+
package serververify
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"io"
+
"net/http"
+
"strings"
+
"time"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/rbac"
+
)
+
+
var (
+
FetchError = errors.New("failed to fetch owner")
+
)
+
+
// fetchOwner fetches the owner DID from a server's /owner endpoint
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
+
scheme := "https"
+
if dev {
+
scheme = "http"
+
}
+
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
+
req, err := http.NewRequest("GET", url, nil)
+
if err != nil {
+
return "", err
+
}
+
+
client := &http.Client{
+
Timeout: 1 * time.Second,
+
}
+
+
resp, err := client.Do(req.WithContext(ctx))
+
if err != nil || resp.StatusCode != 200 {
+
return "", fmt.Errorf("failed to fetch /owner")
+
}
+
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
+
if err != nil {
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
+
}
+
+
did := strings.TrimSpace(string(body))
+
if did == "" {
+
return "", fmt.Errorf("empty DID in /owner response")
+
}
+
+
return did, nil
+
}
+
+
type OwnerMismatch struct {
+
expected string
+
observed string
+
}
+
+
func (e *OwnerMismatch) Error() string {
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
+
}
+
+
// RunVerification verifies that the server at the given domain has the expected owner
+
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
+
observedOwner, err := fetchOwner(ctx, domain, dev)
+
if err != nil {
+
return fmt.Errorf("%w: %w", FetchError, err)
+
}
+
+
if observedOwner != expectedOwner {
+
return &OwnerMismatch{
+
expected: expectedOwner,
+
observed: observedOwner,
+
}
+
}
+
+
return nil
+
}
+
+
// MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner
+
func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
+
tx, err := d.Begin()
+
if err != nil {
+
return 0, fmt.Errorf("failed to create txn: %w", err)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark this spindle as verified in the db
+
rowId, err := db.VerifySpindle(
+
tx,
+
db.FilterEq("owner", owner),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
+
}
+
+
err = e.AddSpindleOwner(instance, owner)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
return rowId, nil
+
}
+
+
// MarkKnotVerified marks a knot as verified and sets up ownership/permissions
+
func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error {
+
tx, err := d.BeginTx(context.Background(), nil)
+
if err != nil {
+
return fmt.Errorf("failed to start tx: %w", err)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark as registered
+
err = db.MarkRegistered(
+
tx,
+
db.FilterEq("did", owner),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to register domain: %w", err)
+
}
+
+
// add basic acls for this domain
+
err = e.AddKnot(domain)
+
if err != nil {
+
return fmt.Errorf("failed to add knot to enforcer: %w", err)
+
}
+
+
// add this did as owner of this domain
+
err = e.AddKnotOwner(domain, owner)
+
if err != nil {
+
return fmt.Errorf("failed to add knot owner to enforcer: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return fmt.Errorf("failed to commit changes: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return fmt.Errorf("failed to update ACLs: %w", err)
+
}
+
+
return nil
+
}
+8 -8
appview/spindles/spindles.go
···
"tangled.sh/tangled.sh/core/appview/middleware"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
+
"tangled.sh/tangled.sh/core/appview/serververify"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
}
// begin verification
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
if err != nil {
l.Error("verification failed", "err", err)
s.Pages.HxRefresh(w)
return
}
-
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
+
_, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.HxRefresh(w)
···
}
// begin verification
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
if err != nil {
l.Error("verification failed", "err", err)
-
if errors.Is(err, verify.FetchError) {
-
s.Pages.Notice(w, noticeId, err.Error())
+
if errors.Is(err, serververify.FetchError) {
+
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
return
}
-
if e, ok := err.(*verify.OwnerMismatch); ok {
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
s.Pages.Notice(w, noticeId, e.Error())
return
}
···
return
}
-
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
+
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
···
-
package spindleverify
-
-
import (
-
"context"
-
"errors"
-
"fmt"
-
"io"
-
"net/http"
-
"strings"
-
"time"
-
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/rbac"
-
)
-
-
var (
-
FetchError = errors.New("failed to fetch owner")
-
)
-
-
// TODO: move this to "spindleclient" or similar
-
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
-
req, err := http.NewRequest("GET", url, nil)
-
if err != nil {
-
return "", err
-
}
-
-
client := &http.Client{
-
Timeout: 1 * time.Second,
-
}
-
-
resp, err := client.Do(req.WithContext(ctx))
-
if err != nil || resp.StatusCode != 200 {
-
return "", fmt.Errorf("failed to fetch /owner")
-
}
-
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
-
if err != nil {
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
-
}
-
-
did := strings.TrimSpace(string(body))
-
if did == "" {
-
return "", fmt.Errorf("empty DID in /owner response")
-
}
-
-
return did, nil
-
}
-
-
type OwnerMismatch struct {
-
expected string
-
observed string
-
}
-
-
func (e *OwnerMismatch) Error() string {
-
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
-
}
-
-
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
-
// begin verification
-
observedOwner, err := fetchOwner(ctx, instance, dev)
-
if err != nil {
-
return fmt.Errorf("%w: %w", FetchError, err)
-
}
-
-
if observedOwner != expectedOwner {
-
return &OwnerMismatch{
-
expected: expectedOwner,
-
observed: observedOwner,
-
}
-
}
-
-
return nil
-
}
-
-
// mark this spindle as verified in the DB and add this user as its owner
-
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
-
tx, err := d.Begin()
-
if err != nil {
-
return 0, fmt.Errorf("failed to create txn: %w", err)
-
}
-
defer func() {
-
tx.Rollback()
-
e.E.LoadPolicy()
-
}()
-
-
// mark this spindle as verified in the db
-
rowId, err := db.VerifySpindle(
-
tx,
-
db.FilterEq("owner", owner),
-
db.FilterEq("instance", instance),
-
)
-
if err != nil {
-
return 0, fmt.Errorf("failed to write to DB: %w", err)
-
}
-
-
err = e.AddSpindleOwner(instance, owner)
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
err = tx.Commit()
-
if err != nil {
-
return 0, fmt.Errorf("failed to commit txn: %w", err)
-
}
-
-
err = e.E.SavePolicy()
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
return rowId, nil
-
}
+3 -3
appview/state/router.go
···
r.Mount("/settings", s.SettingsRouter())
r.Mount("/strings", s.StringsRouter(mw))
-
r.Mount("/knots", s.KnotsRouter(mw))
+
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
···
return spindles.Router()
}
-
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
+
func (s *State) KnotsRouter() http.Handler {
logger := log.New("knots")
knots := &knots.Knots{
···
Logger: logger,
}
-
return knots.Router(mw)
+
return knots.Router()
}
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+5
rbac/rbac.go
···
return err
}
+
func (e *Enforcer) RemoveKnot(knot string) error {
+
_, err := e.E.DeleteDomains(knot)
+
return err
+
}
+
func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) {
keepFunc := isNotSpindle
stripFunc := unSpindle