diff --git a/appview/db/repos.go b/appview/db/repos.go index 8fcc4e2b..276300a7 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -583,16 +583,156 @@ func GetRepoByDid(e Execer, repoDid string) (*models.Repo, error) { return GetRepo(e, orm.FilterEq("repo_did", repoDid)) } +func EnqueuePdsRewritesForRepo(tx *sql.Tx, repoDid, repoAtUri string) error { + type record struct { + userDidCol string + table string + nsid string + fkCol string + } + sources := []record{ + {"did", "repos", "sh.tangled.repo", "at_uri"}, + {"did", "issues", "sh.tangled.repo.issue", "repo_at"}, + {"owner_did", "pulls", "sh.tangled.repo.pull", "repo_at"}, + {"did", "collaborators", "sh.tangled.repo.collaborator", "repo_at"}, + {"did", "artifacts", "sh.tangled.repo.artifact", "repo_at"}, + {"did", "stars", "sh.tangled.feed.star", "subject_at"}, + } + + for _, src := range sources { + rows, err := tx.Query( + fmt.Sprintf(`SELECT %s, rkey FROM %s WHERE %s = ?`, src.userDidCol, src.table, src.fkCol), + repoAtUri, + ) + if err != nil { + return fmt.Errorf("query %s for pds rewrites: %w", src.table, err) + } + + var pairs []struct{ did, rkey string } + for rows.Next() { + var d, r string + if scanErr := rows.Scan(&d, &r); scanErr != nil { + rows.Close() + return fmt.Errorf("scan %s for pds rewrites: %w", src.table, scanErr) + } + pairs = append(pairs, struct{ did, rkey string }{d, r}) + } + rows.Close() + if rowsErr := rows.Err(); rowsErr != nil { + return fmt.Errorf("iterate %s for pds rewrites: %w", src.table, rowsErr) + } + + for _, p := range pairs { + if err := EnqueuePdsRewrite(tx, p.did, repoDid, src.nsid, p.rkey, repoAtUri); err != nil { + return fmt.Errorf("enqueue pds rewrite for %s/%s: %w", src.table, p.rkey, err) + } + } + } + + profileRows, err := tx.Query( + `SELECT DISTINCT did FROM profile_pinned_repositories WHERE at_uri = ?`, + repoAtUri, + ) + if err != nil { + return fmt.Errorf("query profile_pinned_repositories for pds rewrites: %w", err) + } + var profileDids []string + for profileRows.Next() { + var d string + if scanErr := profileRows.Scan(&d); scanErr != nil { + profileRows.Close() + return fmt.Errorf("scan profile_pinned_repositories for pds rewrites: %w", scanErr) + } + profileDids = append(profileDids, d) + } + profileRows.Close() + if profileRowsErr := profileRows.Err(); profileRowsErr != nil { + return fmt.Errorf("iterate profile_pinned_repositories for pds rewrites: %w", profileRowsErr) + } + + for _, d := range profileDids { + if err := EnqueuePdsRewrite(tx, d, repoDid, "sh.tangled.actor.profile", "self", repoAtUri); err != nil { + return fmt.Errorf("enqueue pds rewrite for profile/%s: %w", d, err) + } + } + + return nil +} + +type PdsRewrite struct { + Id int + RepoDid string + RecordNsid string + RecordRkey string + OldRepoAt string +} + +func GetPendingPdsRewrites(e Execer, userDid string) ([]PdsRewrite, error) { + rows, err := e.Query( + `SELECT id, repo_did, record_nsid, record_rkey, old_repo_at + FROM pds_rewrite_status + WHERE user_did = ? AND status = 'pending'`, + userDid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var rewrites []PdsRewrite + for rows.Next() { + var r PdsRewrite + if err := rows.Scan(&r.Id, &r.RepoDid, &r.RecordNsid, &r.RecordRkey, &r.OldRepoAt); err != nil { + return nil, err + } + rewrites = append(rewrites, r) + } + return rewrites, rows.Err() +} + +func CompletePdsRewrite(e Execer, id int) error { + _, err := e.Exec( + `UPDATE pds_rewrite_status SET status = 'done', updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`, + id, + ) + return err +} + func EnqueuePdsRewrite(e Execer, userDid, repoDid, recordNsid, recordRkey, oldRepoAt string) error { _, err := e.Exec( - `INSERT OR IGNORE INTO pds_rewrite_status + `INSERT INTO pds_rewrite_status (user_did, repo_did, record_nsid, record_rkey, old_repo_at, status) - VALUES (?, ?, ?, ?, ?, 'pending')`, + VALUES (?, ?, ?, ?, ?, 'pending') + ON CONFLICT(user_did, record_nsid, record_rkey) DO UPDATE SET + status = 'pending', + repo_did = excluded.repo_did, + old_repo_at = excluded.old_repo_at, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`, userDid, repoDid, recordNsid, recordRkey, oldRepoAt, ) return err } +func CascadeRepoDid(tx *sql.Tx, repoAtUri, repoDid string) error { + _, err := tx.Exec( + `UPDATE repos SET repo_did = ? WHERE at_uri = ?`, + repoDid, repoAtUri, + ) + if err != nil { + return fmt.Errorf("cascade repo_did to repos: %w", err) + } + + _, err = tx.Exec( + `UPDATE repos SET source = ? WHERE source = ?`, + repoDid, repoAtUri, + ) + if err != nil { + return fmt.Errorf("cascade repo_did to repos.source: %w", err) + } + + return nil +} + func UpdateDescription(e Execer, repoAt, newDescription string) error { _, err := e.Exec( `update repos set description = ? where at_uri = ?`, newDescription, repoAt) diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index c4f5532e..bfbbeff5 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -13,6 +13,7 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/auth/oauth" lexutil "github.com/bluesky-social/indigo/lex/util" xrpc "github.com/bluesky-social/indigo/xrpc" @@ -95,6 +96,7 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { go o.addToDefaultSpindle(sessData.AccountDID.String()) go o.ensureTangledProfile(sessData) go o.autoClaimTnglShDomain(sessData.AccountDID.String()) + go o.drainPdsRewrites(sessData) if !o.Config.Core.Dev { err = o.Posthog.Enqueue(posthog.Capture{ @@ -273,6 +275,138 @@ func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { l.Debug("successfully created empty Tangled profile on PDS and DB") } +func (o *OAuth) drainPdsRewrites(sessData *oauth.ClientSessionData) { + ctx := context.Background() + did := sessData.AccountDID.String() + l := o.Logger.With("did", did, "handler", "drainPdsRewrites") + + rewrites, err := db.GetPendingPdsRewrites(o.Db, did) + if err != nil { + l.Error("failed to get pending rewrites", "err", err) + return + } + if len(rewrites) == 0 { + return + } + + l.Info("draining pending PDS rewrites", "count", len(rewrites)) + + sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) + if err != nil { + l.Error("failed to resume session for PDS rewrites", "err", err) + return + } + client := sess.APIClient() + + for _, rw := range rewrites { + if err := o.rewritePdsRecord(ctx, client, did, rw); err != nil { + l.Error("failed to rewrite PDS record", + "nsid", rw.RecordNsid, + "rkey", rw.RecordRkey, + "repo_did", rw.RepoDid, + "err", err) + continue + } + + if err := db.CompletePdsRewrite(o.Db, rw.Id); err != nil { + l.Error("failed to mark rewrite complete", "id", rw.Id, "err", err) + } + } +} + +func (o *OAuth) rewritePdsRecord(ctx context.Context, client *atpclient.APIClient, userDid string, rw db.PdsRewrite) error { + ex, err := comatproto.RepoGetRecord(ctx, client, "", rw.RecordNsid, userDid, rw.RecordRkey) + if err != nil { + return fmt.Errorf("get record: %w", err) + } + + val := ex.Value.Val + repoDid := rw.RepoDid + + switch rw.RecordNsid { + case tangled.RepoNSID: + rec, ok := val.(*tangled.Repo) + if !ok { + return fmt.Errorf("unexpected type for repo record") + } + rec.RepoDid = &repoDid + + case tangled.RepoIssueNSID: + rec, ok := val.(*tangled.RepoIssue) + if !ok { + return fmt.Errorf("unexpected type for issue record") + } + rec.RepoDid = &repoDid + + case tangled.RepoPullNSID: + rec, ok := val.(*tangled.RepoPull) + if !ok { + return fmt.Errorf("unexpected type for pull record") + } + if rec.Target != nil { + rec.Target.RepoDid = &repoDid + } + if rec.Source != nil && rec.Source.Repo != nil && *rec.Source.Repo == rw.OldRepoAt { + rec.Source.RepoDid = &repoDid + } + + case tangled.RepoCollaboratorNSID: + rec, ok := val.(*tangled.RepoCollaborator) + if !ok { + return fmt.Errorf("unexpected type for collaborator record") + } + rec.RepoDid = &repoDid + + case tangled.RepoArtifactNSID: + rec, ok := val.(*tangled.RepoArtifact) + if !ok { + return fmt.Errorf("unexpected type for artifact record") + } + rec.RepoDid = &repoDid + + case tangled.FeedStarNSID: + rec, ok := val.(*tangled.FeedStar) + if !ok { + return fmt.Errorf("unexpected type for star record") + } + rec.SubjectDid = &repoDid + + case tangled.ActorProfileNSID: + rec, ok := val.(*tangled.ActorProfile) + if !ok { + return fmt.Errorf("unexpected type for profile record") + } + var dids []string + var remaining []string + for _, pinUri := range rec.PinnedRepositories { + repo, repoErr := db.GetRepoByAtUri(o.Db, pinUri) + if repoErr != nil || repo.RepoDid == "" { + remaining = append(remaining, pinUri) + continue + } + dids = append(dids, repo.RepoDid) + } + rec.PinnedRepositoryDids = append(rec.PinnedRepositoryDids, dids...) + rec.PinnedRepositories = remaining + + default: + return fmt.Errorf("unsupported NSID for PDS rewrite: %s", rw.RecordNsid) + } + + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ + Collection: rw.RecordNsid, + Repo: userDid, + Rkey: rw.RecordRkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{Val: val}, + }) + if err != nil { + return fmt.Errorf("put record: %w", err) + } + + return nil +} + // create a AppPasswordSession using apppasswords type AppPasswordSession struct { AccessJwt string `json:"accessJwt"` diff --git a/appview/state/knotstream.go b/appview/state/knotstream.go index 86601bad..540ccd14 100644 --- a/appview/state/knotstream.go +++ b/appview/state/knotstream.go @@ -20,6 +20,7 @@ import ( "tangled.org/core/appview/sites" ec "tangled.org/core/eventconsumer" "tangled.org/core/eventconsumer/cursor" + knotdb "tangled.org/core/knotserver/db" "tangled.org/core/log" "tangled.org/core/orm" "tangled.org/core/rbac" @@ -88,6 +89,8 @@ func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, not return ingestRefUpdate(ctx, d, enforcer, posthog, notifier, dev, c, cfClient, source, msg) case tangled.PipelineNSID: return ingestPipeline(d, source, msg) + case knotdb.RepoDIDAssignNSID: + return ingestDIDAssign(d, enforcer, source, msg, ctx) } return nil @@ -375,3 +378,90 @@ func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { return nil } + +func ingestDIDAssign(d *db.DB, enforcer *rbac.Enforcer, source ec.Source, msg ec.Message, ctx context.Context) error { + logger := log.FromContext(ctx) + + var record knotdb.RepoDIDAssign + if err := json.Unmarshal(msg.EventJson, &record); err != nil { + return fmt.Errorf("unmarshal didAssign: %w", err) + } + + if record.RepoDid == "" || record.OwnerDid == "" || record.RepoName == "" { + return fmt.Errorf("didAssign missing required fields: repoDid=%q ownerDid=%q repoName=%q", + record.RepoDid, record.OwnerDid, record.RepoName) + } + + logger.Info("processing didAssign event", + "repo_did", record.RepoDid, + "owner_did", record.OwnerDid, + "repo_name", record.RepoName) + + repos, err := db.GetRepos(d, + orm.FilterEq("did", record.OwnerDid), + orm.FilterEq("name", record.RepoName), + ) + if err != nil || len(repos) == 0 { + logger.Warn("didAssign for unknown repo, skipping", + "owner_did", record.OwnerDid, + "repo_name", record.RepoName) + return nil + } + repo := repos[0] + knot := source.Key() + + if repo.Knot != knot { + return fmt.Errorf("didAssign from %s for repo hosted on %s, rejecting", knot, repo.Knot) + } + + repoAtUri := repo.RepoAt().String() + legacyResource := record.OwnerDid + "/" + record.RepoName + + if repo.RepoDid != record.RepoDid { + tx, err := d.Begin() + if err != nil { + return fmt.Errorf("begin didAssign txn: %w", err) + } + defer tx.Rollback() + + if err := db.CascadeRepoDid(tx, repoAtUri, record.RepoDid); err != nil { + return fmt.Errorf("cascade repo_did: %w", err) + } + + if err := db.EnqueuePdsRewritesForRepo(tx, record.RepoDid, repoAtUri); err != nil { + return fmt.Errorf("enqueue pds rewrites: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit didAssign txn: %w", err) + } + } + + if err := enforcer.RemoveRepo(record.OwnerDid, knot, legacyResource); err != nil { + return fmt.Errorf("remove legacy RBAC policies for %s: %w", legacyResource, err) + } + if err := enforcer.AddRepo(record.OwnerDid, knot, record.RepoDid); err != nil { + return fmt.Errorf("add RBAC policies for %s: %w", record.RepoDid, err) + } + + collabs, collabErr := db.GetCollaborators(d, orm.FilterEq("repo_at", repoAtUri)) + if collabErr != nil { + return fmt.Errorf("get collaborators for RBAC update: %w", collabErr) + } + for _, c := range collabs { + collabDid := c.SubjectDid.String() + if err := enforcer.RemoveCollaborator(collabDid, knot, legacyResource); err != nil { + return fmt.Errorf("remove collaborator RBAC for %s: %w", collabDid, err) + } + if err := enforcer.AddCollaborator(collabDid, knot, record.RepoDid); err != nil { + return fmt.Errorf("add collaborator RBAC for %s: %w", collabDid, err) + } + } + + logger.Info("didAssign processed successfully", + "repo_did", record.RepoDid, + "owner_did", record.OwnerDid, + "repo_name", record.RepoName) + + return nil +} diff --git a/knotserver/db/didassign.go b/knotserver/db/didassign.go new file mode 100644 index 00000000..6f20d42a --- /dev/null +++ b/knotserver/db/didassign.go @@ -0,0 +1,10 @@ +package db + +const RepoDIDAssignNSID = "sh.tangled.repo.didAssign" + +type RepoDIDAssign struct { + OwnerDid string `json:"ownerDid"` + RepoName string `json:"repoName"` + RepoDid string `json:"repoDid"` + OldRepoAt string `json:"oldRepoAt,omitempty"` +} diff --git a/knotserver/db/events.go b/knotserver/db/events.go index 9b154aae..09612ba7 100644 --- a/knotserver/db/events.go +++ b/knotserver/db/events.go @@ -1,10 +1,12 @@ package db import ( + "encoding/json" "fmt" "time" "tangled.org/core/notifier" + "tangled.org/core/tid" ) type Event struct { @@ -29,6 +31,26 @@ func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { return err } +func (d *DB) EmitDIDAssign(n *notifier.Notifier, ownerDid, repoName, repoDid, oldRepoAt string) error { + payload := RepoDIDAssign{ + OwnerDid: ownerDid, + RepoName: repoName, + RepoDid: repoDid, + OldRepoAt: oldRepoAt, + } + + eventJson, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal didAssign event: %w", err) + } + + return d.InsertEvent(Event{ + Rkey: tid.TID(), + Nsid: RepoDIDAssignNSID, + EventJson: string(eventJson), + }, n) +} + func (d *DB) GetEvents(cursor int64) ([]Event, error) { whereClause := "" args := []any{} diff --git a/knotserver/events.go b/knotserver/events.go index 5e345977..40e24151 100644 --- a/knotserver/events.go +++ b/knotserver/events.go @@ -56,9 +56,8 @@ func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { cursor = defaultCursor } - // complete backfill first before going to live data l.Debug("going through backfill", "cursor", cursor) - if err := h.streamOps(conn, &cursor); err != nil { + if err := h.drainBackfill(conn, &cursor, 10_000); err != nil { l.Error("failed to backfill", "err", err) return } @@ -81,9 +80,8 @@ func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { l.Debug("stopping stream: client closed connection") return case <-ch: - // we have been notified of new data l.Debug("going through live data", "cursor", cursor) - if err := h.streamOps(conn, &cursor); err != nil { + if _, err := h.streamOps(conn, &cursor); err != nil { l.Error("failed to stream", "err", err) return } @@ -96,40 +94,54 @@ func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { } } -func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { +func (h *Knot) drainBackfill(conn *websocket.Conn, cursor *int64, maxBatches int) error { + for range maxBatches { + n, err := h.streamOps(conn, cursor) + if err != nil { + return err + } + if n < 100 { + return nil + } + } + h.l.Warn("backfill hit batch limit", "maxBatches", maxBatches, "cursor", *cursor) + return nil +} + +func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) (int, error) { events, err := h.db.GetEvents(*cursor) if err != nil { h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) - return err + return 0, err } for _, event := range events { - // first extract the inner json into a map var eventJson map[string]any err := json.Unmarshal([]byte(event.EventJson), &eventJson) if err != nil { h.l.Error("failed to unmarshal event", "err", err) - return err + return 0, err } jsonMsg, err := json.Marshal(map[string]any{ - "rkey": event.Rkey, - "nsid": event.Nsid, - "event": eventJson, + "rkey": event.Rkey, + "nsid": event.Nsid, + "event": eventJson, + "created": event.Created, }) if err != nil { h.l.Error("failed to marshal record", "err", err) - return err + return 0, err } if err := conn.WriteMessage(websocket.TextMessage, jsonMsg); err != nil { h.l.Debug("err", "err", err) - return err + return 0, err } *cursor = event.Created } - return nil + return len(events), nil } func (h *Knot) requestCrawl(ctx context.Context) error { diff --git a/knotserver/git.go b/knotserver/git.go index 707fb9d9..b407b1f8 100644 --- a/knotserver/git.go +++ b/knotserver/git.go @@ -6,8 +6,10 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "tangled.org/core/knotserver/git/service" ) @@ -25,12 +27,19 @@ func (h *Knot) resolveRepoPath(r *http.Request) (string, string, error) { } repoDid, err := h.db.GetRepoDid(did, name) - if err != nil { - return "", "", fmt.Errorf("repo not found: %w", err) + if err == nil { + repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) + if resolveErr == nil { + return repoPath, name, nil + } } - repoPath, _, _, err := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) - if err != nil { - return "", "", fmt.Errorf("repo not found: %w", err) + + repoPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) + if joinErr != nil { + return "", "", fmt.Errorf("repo not found: %w", joinErr) + } + if _, statErr := os.Stat(repoPath); statErr != nil { + return "", "", fmt.Errorf("repo not found: %w", statErr) } return repoPath, name, nil } diff --git a/knotserver/internal.go b/knotserver/internal.go index f1dbc3e3..de6af34c 100644 --- a/knotserver/internal.go +++ b/knotserver/internal.go @@ -6,9 +6,11 @@ import ( "fmt" "log/slog" "net/http" + "os" "path/filepath" "strings" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-git/go-git/v5/plumbing" @@ -124,20 +126,33 @@ func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { ownerDid := repoOwnerIdent.DID.String() repoName := components[1] repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName) - if didErr != nil { - w.WriteHeader(http.StatusNotFound) - l.Error("repo DID not found", "owner", ownerDid, "name", repoName, "err", didErr) - fmt.Fprintln(w, "repo not found") - return - } - repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) - if lookupErr != nil { - w.WriteHeader(http.StatusNotFound) - l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) - fmt.Fprintln(w, "repo not found") - return + var repoPath string + if didErr == nil { + var lookupErr error + repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) + if lookupErr != nil { + w.WriteHeader(http.StatusNotFound) + l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) + fmt.Fprintln(w, "repo not found") + return + } + rbacResource = repoDid + } else { + legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid, repoName)) + if joinErr != nil { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintln(w, "repo not found") + return + } + if _, statErr := os.Stat(legacyPath); statErr != nil { + w.WriteHeader(http.StatusNotFound) + l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName) + fmt.Fprintln(w, "repo not found") + return + } + repoPath = legacyPath + rbacResource = ownerDid + "/" + repoName } - rbacResource = repoDid rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) if relErr != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/rbac/rbac.go b/rbac/rbac.go index 2326ed1e..ce8aa3d6 100644 --- a/rbac/rbac.go +++ b/rbac/rbac.go @@ -34,7 +34,7 @@ m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom) ) type Enforcer struct { - E *casbin.Enforcer + E *casbin.SyncedEnforcer } func NewEnforcer(path string) (*Enforcer, error) { @@ -53,7 +53,7 @@ func NewEnforcer(path string) (*Enforcer, error) { return nil, err } - e, err := casbin.NewEnforcer(m, a) + e, err := casbin.NewSyncedEnforcer(m, a) if err != nil { return nil, err } diff --git a/rbac/rbac_test.go b/rbac/rbac_test.go index cd1d4198..996890fa 100644 --- a/rbac/rbac_test.go +++ b/rbac/rbac_test.go @@ -23,7 +23,7 @@ func setup(t *testing.T) *rbac.Enforcer { m, err := model.NewModelFromString(rbac.Model) assert.NoError(t, err) - e, err := casbin.NewEnforcer(m, a) + e, err := casbin.NewSyncedEnforcer(m, a) assert.NoError(t, err) e.EnableAutoSave(false)