oficina / docs Public

Add unmounted TeamMode permission scope and lease boundary tests

Paul Campbell pushed by paul@keymail.dev 308f095cd1833356d42ac208ccccaa0b7cd95277 parent 7b52265
3 files changed, +343 −1
  • cmd/docs/main.go +1 −1
  • internal/docs/team_access.go +141 −0
  • internal/docs/team_access_test.go +201 −0

CI passed — run details

diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 521980f..9d27011 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -315,7 +315,7 @@ func main() {
logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err)
os.Exit(1)
}
- if err := docs.Seed(context.Background(), app.Store(), docs.Actor{PersonID: owner, Subject: seedAddress}); err != nil {
+ if err := docs.Seed(context.Background(), app.Store(), docs.Actor{PersonID: owner, VerifiedAddress: &seedAddress}); err != nil {
logger.Error("docs: seed", "address", seedAddress, "err", err)
os.Exit(1)
}
diff --git a/internal/docs/team_access.go b/internal/docs/team_access.go
new file mode 100644
index 0000000..80c5cff
--- /dev/null
+++ b/internal/docs/team_access.go
@@ -0,0 +1,141 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo"
+ "gorm.io/gorm"
+)
+
+type accessContextKey struct{}
+type requestAccess struct {
+ runtime *teamRuntime
+ actor Actor
+ subject string
+ decision roster.Decision
+}
+type permission uint8
+
+const (
+ permissionRead permission = iota
+ permissionWrite
+ permissionOwner
+)
+
+var errTeamNotMember = errors.New("docs: team membership required")
+
+func sameActor(a, b Actor) bool {
+ if a.PersonID != b.PersonID || (a.VerifiedAddress == nil) != (b.VerifiedAddress == nil) {
+ return false
+ }
+ return a.VerifiedAddress == nil || *a.VerifiedAddress == *b.VerifiedAddress
+}
+func (a *requestAccess) state() roster.State {
+ if a == nil {
+ return roster.Unknown
+ }
+ if a.decision.State == roster.Member && !a.decision.Lease.Valid() {
+ return roster.Unknown
+ }
+ return a.decision.State
+}
+func (s *Store) access(ctx context.Context, actor Actor) (*requestAccess, bool) {
+ a, ok := ctx.Value(accessContextKey{}).(*requestAccess)
+ return a, ok && a != nil && a.runtime == s.team && sameActor(a.actor, actor)
+}
+func (t *teamRuntime) resolveAccess(ctx context.Context, id requestIdentity) *requestAccess {
+ a := &requestAccess{runtime: t, actor: id.Actor, subject: id.HomeSubject, decision: roster.Decision{State: roster.NotMember}}
+ if id.HomeSubject != "" {
+ a.decision = t.cache.Check(ctx, t.bindings.Issuer, t.bindings.Team, id.HomeSubject)
+ }
+ return a
+}
+func (t *teamRuntime) admission(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, ok := currentIdentity(r)
+ if !ok {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ return
+ }
+ a := t.resolveAccess(r.Context(), id)
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), accessContextKey{}, a)))
+ })
+}
+
+// permissionScope is also usable under the SQLite writer. It never refreshes
+// the roster, and checks a lease without waiting for a cache publisher.
+func (s *Store) permissionScope(ctx context.Context, g *gorm.DB, actor Actor, p permission) *gorm.DB {
+ owner := s.team == nil
+ if s.team != nil {
+ a, ok := s.access(ctx, actor)
+ if !ok {
+ return g.Where("1 = 0")
+ }
+ owner = a.state() == roster.Member
+ }
+ if actor.PersonID <= 0 {
+ return g.Where("1 = 0")
+ }
+ if p == permissionOwner {
+ if !owner {
+ return g.Where("1 = 0")
+ }
+ return owned(g, actor.PersonID)
+ }
+ grant := actor.VerifiedAddress != nil && *actor.VerifiedAddress != ""
+ if !owner && !grant {
+ return g.Where("1 = 0")
+ }
+ clause := "documents.owner_id > 0 AND ("
+ args := []any{}
+ if owner {
+ clause += "documents.owner_id = ?"
+ args = append(args, actor.PersonID)
+ }
+ if grant {
+ if owner {
+ clause += " OR "
+ }
+ clause += "EXISTS (SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?"
+ args = append(args, *actor.VerifiedAddress)
+ if p == permissionWrite {
+ clause += " AND grants.role = ?"
+ args = append(args, RoleWriter)
+ }
+ clause += ")"
+ }
+ return g.Where(clause+")", args...)
+}
+func (s *Store) permissionError(ctx context.Context, actor Actor, err error) error {
+ if s.team == nil || err == nil {
+ return err
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return errTeamUnavailable
+ }
+ a, ok := s.access(ctx, actor)
+ if !ok || a.state() == roster.Unknown {
+ return errTeamUnavailable
+ }
+ return gorm.ErrRecordNotFound
+}
+func (s *Store) requireMember(ctx context.Context, actor Actor) error {
+ if s.team == nil {
+ return nil
+ }
+ a, ok := s.access(ctx, actor)
+ if !ok {
+ return errTeamUnavailable
+ }
+ switch a.state() {
+ case roster.Member:
+ return nil
+ case roster.NotMember:
+ return errTeamNotMember
+ default:
+ return errTeamUnavailable
+ }
+}
diff --git a/internal/docs/team_access_test.go b/internal/docs/team_access_test.go
new file mode 100644
index 0000000..b3fccf6
--- /dev/null
+++ b/internal/docs/team_access_test.go
@@ -0,0 +1,201 @@
+package docs
+
+import (
+ "amadan.net/oficina/home/handoff/seal"
+ "amadan.net/oficina/home/roster"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "github.com/carlosframework/docs/internal/docs/teamstate"
+ "github.com/carlosframework/rastrillo/migrate"
+ "gorm.io/gorm"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestTeamPermissionFormerOwnerOnlyReachesExactGrant(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ s := app.Store()
+ ctx := context.Background()
+ owner, e := s.EnsurePerson(ctx, "former@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ other, e := s.EnsurePerson(ctx, "other@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ private, e := s.Create(ctx, owner, "formerly owned")
+ if e != nil {
+ t.Fatal(e)
+ }
+ shared, e := s.Create(ctx, other, "individual grant")
+ if e != nil {
+ t.Fatal(e)
+ }
+ stranger, e := s.Create(ctx, other, "other private")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = s.Grant(ctx, other, shared.Sqid, "former@example.com", RoleWriter); e != nil {
+ t.Fatal(e)
+ }
+ s.team = &teamRuntime{}
+ actor := localActor(owner, "former@example.com")
+ for _, state := range []roster.State{roster.NotMember, roster.Unknown} {
+ a := &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: state}}
+ c := context.WithValue(ctx, accessContextKey{}, a)
+ for _, mode := range []permission{permissionRead, permissionWrite, permissionOwner} {
+ for _, doc := range []*Document{private, shared, stranger} {
+ var count int64
+ e := s.permissionScope(c, s.db.Model(&Document{}), actor, mode).Where("documents.id = ?", doc.ID).Count(&count).Error
+ if e != nil {
+ t.Fatal(e)
+ }
+ want := int64(0)
+ if doc == shared && mode != permissionOwner {
+ want = 1
+ }
+ if count != want {
+ t.Fatalf("state %v mode %v document %v: got %d want %d", state, mode, doc.ID, count, want)
+ }
+ }
+ }
+ e := s.permissionError(c, actor, gorm.ErrRecordNotFound)
+ if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) {
+ t.Fatal("uncertain absence was not retryable")
+ }
+ if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) {
+ t.Fatal("known refusal was not definite")
+ }
+ if e = s.requireMember(c, actor); e == nil {
+ t.Fatal("grant became membership")
+ }
+ }
+ for _, c := range []context.Context{ctx, context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: &teamRuntime{}, actor: actor})} {
+ var n int64
+ if e = s.permissionScope(c, s.db.Model(&Document{}), actor, permissionRead).Count(&n).Error; e != nil || n != 0 {
+ t.Fatal("missing or foreign access context was accepted")
+ }
+ }
+ c := context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}})
+ var n int64
+ if e = s.permissionScope(c, s.db.Model(&Document{}), localActor(other, "former@example.com"), permissionRead).Count(&n).Error; e != nil || n != 0 {
+ t.Fatal("actor mismatch accepted")
+ }
+}
+
+type teamTestClock struct {
+ mu sync.Mutex
+ elapsed time.Duration
+}
+
+func (c *teamTestClock) Now() (time.Time, time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return time.Unix(1800000000, 0).Add(c.elapsed), c.elapsed
+}
+func (c *teamTestClock) step(d time.Duration) { c.mu.Lock(); defer c.mu.Unlock(); c.elapsed += d }
+
+type teamTestTransport func(*http.Request) (*http.Response, error)
+
+func (f teamTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+func testTeamRoster(t *testing.T, s *Store, app *App) (*teamRuntime, *teamTestClock, string) {
+ t.Helper()
+ op, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ key, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ fixed := func(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 32)) }
+ b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"}
+ p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: fixed(1), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}}
+ raw, e := roster.SignProvisioning(op, "operator1", p)
+ if e != nil {
+ t.Fatal(e)
+ }
+ record, e := roster.VerifyProvisioning(raw, []roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, b)
+ if e != nil {
+ t.Fatal(e)
+ }
+ sealer, e := seal.New(bytes.Repeat([]byte{9}, 32), "team-test")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil {
+ t.Fatal(e)
+ }
+ clock := &teamTestClock{}
+ subject := "pr_" + strings.Repeat("a", 26)
+ transport := teamTestTransport(func(r *http.Request) (*http.Response, error) {
+ _, elapsed := clock.Now()
+ if elapsed >= 30*time.Second {
+ return nil, errors.New("directory unavailable")
+ }
+ i, complete := 0, true
+ members := []roster.MemberEntry{{Subject: subject, Role: "member"}}
+ page := roster.Page{Version: 1, Type: "page", Directory: b.Directory, Consumer: b.Consumer, Team: b.Team, Audience: b.Audience, Fetch: r.URL.Query().Get("fetch"), RequestCursorHash: roster.CursorHash(r.URL.Query().Get("cursor")), Generation: p.Generation, Epoch: 1, Index: &i, Members: &members, Complete: &complete}
+ raw, e := roster.SignPage(key, "roster1", page)
+ if e != nil {
+ return nil, e
+ }
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil
+ })
+ cache, e := roster.New(t.Context(), roster.Config{Record: record, Token: fixed(7), Clock: clock, Sealer: sealer, Durable: teamstate.Evidence{DB: app.db.Writer()}, Transport: transport})
+ if e != nil {
+ t.Fatal(e)
+ }
+ rt := &teamRuntime{cache: cache, bindings: b, db: app.db.Writer()}
+ s.team = rt
+ return rt, clock, subject
+}
+
+func TestTeamPermissionMemberLeaseEndsAtSixtySeconds(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ s := app.Store()
+ ctx := t.Context()
+ owner, e := s.EnsurePerson(ctx, "owner@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ doc, e := s.Create(ctx, owner, "owned")
+ if e != nil {
+ t.Fatal(e)
+ }
+ rt, clock, subject := testTeamRoster(t, s, app)
+ actor := localActor(owner, "owner@example.com")
+ access := rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject})
+ if access.state() != roster.Member {
+ t.Fatal("signed member refused")
+ }
+ ctx = context.WithValue(ctx, accessContextKey{}, access)
+ check := func(want int64) {
+ t.Helper()
+ var n int64
+ e := s.permissionScope(ctx, s.db.Model(&Document{}), actor, permissionOwner).Where("id = ?", doc.ID).Count(&n).Error
+ if e != nil || n != want {
+ t.Fatalf("owner access count %d want %d: %v", n, want, e)
+ }
+ }
+ check(1)
+ clock.step(30 * time.Second)
+ if e = rt.cache.Refresh(ctx); e == nil {
+ t.Fatal("failed refresh accepted")
+ }
+ check(1)
+ clock.step(30 * time.Second)
+ check(0)
+ if e = s.requireMember(ctx, actor); !errors.Is(e, errTeamUnavailable) {
+ t.Fatal("expired member edit must retry")
+ }
+}