diff --git a/CHANGELOG.md b/CHANGELOG.md index e8dbb09..47c95fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ Notable changes to this library, newest first. Versions are git tags; this file for whoever bumps the dependency — what changed, and what it means for code that already uses it. +## v0.29.0 + +### Added — `permissions`: a third plane, `Chart` + +A permission held by a position in a tenant's chart of authority, relative to the tree, is declared with +`Plane: permissions.Chart`. It lifts what the people below the holder already see; it is never ticked on a role +or a user type and never seeds a role, so it must be ordinary in class and without seeds, or the service stops +at load. The test kit's *declared is checked* rule skips it, since a service reads it from the viewer's reach +and not on a route. Nothing existing changed. A membership register must accept the plane `chart` before a +section that declares one is applied to it. + +### Added — `chart`: a service's copy of who is below whom + +A service that lets a person see what the people below them in the chart see keeps a copy of the tree as a list, +and this package keeps it current and honest the way `placement` keeps the roles': the register is asked every few +seconds with the version held (a `304` while nothing changed), a changed answer replaces the copy in one +transaction the service's store makes atomic, and a copy is trusted for one window after it was last confirmed +and no longer. A move in the chart is seen within one interval; a copy past the window lifts nothing, and `Ready` +names the tenants it concerns. The register is reached as the service itself with the level `membership:chart`. + ## v0.28.0 ### Added — `permissions`: a family of rights, one per field diff --git a/README.md b/README.md index 43f527e..efbc1df 100644 --- a/README.md +++ b/README.md @@ -269,8 +269,8 @@ v1.Get("/config", gate.Member(permissions.Levels("projects", "read"), r.configGe itself. - **Each permission says** its `Class` (whether it changes what others may do, or is a family of rights, one per field), - its `Plane` (granted to the whole `Tenant`, or on one `Object` the service - owns), its label per language (`Label(lang)` falls back to the description) and + its `Plane` (granted to the whole `Tenant`, on one `Object` the service + owns, or to a position in the tenant's `Chart` of authority), its label per language (`Label(lang)` falls back to the description) and whether it is `Retired` — kept for the roles that already hold it, never handed out again. A permission is never removed from a list. - **Seeds**: an ordinary permission on the object plane may name the roles the @@ -348,11 +348,46 @@ if err := keeper.Ready(); err != nil { /* degraded: placed keys grant nothing */ The register is reached as the service itself, a member of each tenant, holding `membership:definitions` and `membership:placements`. +### A copy of a tenant's chart of authority (`chart`) + +A tenant may draw its own tree of positions and put its people in them, and a +service may let a person see what the people below them see — the projects of +everyone who reports to them — without joining any of them. The service reads that +from a copy of the tree, kept the way the roles' copy is kept: + +```go +reg, _ := chart.NewRegister(authClient, "http://membership:8080", "membership") +keeper, _ := chart.New(reg, myStore, chart.Config{}) // 10 s poll, 15 min trust window +go keeper.Run(ctx) + +keeper.Wake(tenant) // a tenant this service has never read the chart of +since := keeper.TrustedSince() // a copy confirmed before this lifts nothing +if err := keeper.Ready(); err != nil { /* degraded: the chart lifts nothing */ } +``` + +- **The copy is a list**: for each person, everyone below them, all the way down, + as subject keys (`Chart.Below`). Nobody is below themselves, and a person with + nobody below has no entry. +- **Same discipline as the roles' copy**: the poll sends the version held and a + `304` costs nothing, a changed answer replaces the copy in one `Store.Replace`, + and a copy is trusted for one window after it was last confirmed. A person moved + out of a position stops seeing through it within one interval, and when the + register cannot be reached, within the window. +- **The store** has four methods: the tenants it holds, a copy's version, replace, + confirm. The register is reached as the service itself, holding + `membership:chart`; a tenant without a chart is answered as an empty one. +- **The permissions that read the copy** are declared on the third plane, + `permissions.Chart`: held by a position, relative to the tree, ordinary in + class, never ticked on a role or a user type and never seeded. The test kit's + *declared is checked* rule skips them, since they are read from the viewer's + reach rather than checked on a route. + ## Packages ``` asclient/ Confidential authorization-code browser login (PKCE + per-session proof) authclient/ Configuration, Client, Azugo middleware, outbound calls +chart/ A service's copy of who is below whom in a tenant's chart of authority claims/ Shared JWT claim model (user + service + delegated tokens; `act`) dpop/ RFC 9449 proof generation & verification, JWK thumbprint identitycode/ One spelling of an identity code — store, compare, show diff --git a/chart/chart.go b/chart/chart.go new file mode 100644 index 0000000..e07d46c --- /dev/null +++ b/chart/chart.go @@ -0,0 +1,274 @@ +// Package chart keeps a service's copy of who is below whom in a tenant's chart +// of authority, for a service that lets a position see what the people below it +// see. +// +// A tenant draws its own tree of positions and puts its people in them. A service +// that lets a person see more because of where they sit — the projects of +// everyone who reports to them — must not ask the membership register on every +// request. So it keeps a copy of the tree as a list: for each person, everyone +// below them, all the way down. A read checks the copy: a local read. +// +// This package keeps that copy current and honest, the way package placement +// keeps a copy of the roles: +// +// - it asks the membership register for the tenant's chart every few seconds, +// sending the version it holds, so that while nothing changed the answer is a +// 304 with no body ([Keeper.Run]); +// - a changed answer replaces the copy, in one step the service's [Store] makes +// atomic; +// - a copy is trusted for one window after it was last confirmed and no longer: +// a move in the chart is seen within one interval, and a copy older than the +// window lifts nothing, so a person who was taken out of a position stops +// seeing through it even if the register cannot be reached ([Keeper.Ready] +// reports it). +// +// The register is reached through a [Source]; [NewRegister] is the one that calls +// it over the network with the service's own credential. The copy lives wherever +// the service keeps its data, behind a [Store]. A tenant without a chart, or +// whose chart is not included, is answered as an empty one. +package chart + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/gmb-lib/go-authbyte/placement" +) + +// Defaults for [Config], the same as the roles' copy so that one reader sees both +// change together. +const ( + // DefaultInterval is how often the register is asked. + DefaultInterval = placement.DefaultInterval + // DefaultTrustWindow is how long a confirmed copy is trusted. + DefaultTrustWindow = placement.DefaultTrustWindow +) + +// Config shapes a [Keeper]. Zero values take the defaults. +type Config = placement.Config + +// Chart is a tenant's chart as a service copies it: for each person, the people +// below them, all the way down, each spelled as a subject key. A person with +// nobody below them has no entry, and nobody is below themselves. +type Chart struct { + // Version is what the register answered the chart at. + Version string + // Below maps a subject to every subject below them. + Below map[string][]string +} + +// Source is the membership register as the copy sees it. +type Source interface { + // Chart answers the tenant's chart. Given the version the caller holds, it + // answers changed=false while that version is still current. + Chart(ctx context.Context, tenant, version string) (c Chart, changed bool, err error) +} + +// Store is where the service keeps its copy. +type Store interface { + // Tenants answers every tenant the service holds a copy for. + Tenants(ctx context.Context) ([]string, error) + // Version answers the version of the tenant's copy, empty when it has none. + Version(ctx context.Context, tenant string) (string, error) + // Replace makes c the tenant's copy, confirmed at the time given. It must be + // one transaction: a read must never see half of the old chart and half of + // the new. + Replace(ctx context.Context, tenant string, c Chart, at time.Time) error + // Confirm records that the tenant's copy was found current at the time given. + Confirm(ctx context.Context, tenant string, at time.Time) error +} + +// Keeper keeps every tenant's copy current. It is safe for concurrent use. +type Keeper struct { + src Source + st Store + cfg Config + + wake chan string + + mu sync.Mutex + started bool // the first cycle has run + confirmed map[string]time.Time // last confirmation per tenant, this process + extra map[string]bool // tenants woken that the store does not list yet +} + +// New answers a Keeper over the register and the service's store. +func New(src Source, st Store, cfg Config) (*Keeper, error) { + if src == nil || st == nil { + return nil, errors.New("chart: a source and a store are required") + } + if cfg.Interval < 0 || cfg.TrustWindow < 0 { + return nil, errors.New("chart: the interval and the trust window cannot be negative") + } + if cfg.Interval == 0 { + cfg.Interval = DefaultInterval + } + if cfg.TrustWindow == 0 { + cfg.TrustWindow = DefaultTrustWindow + } + if cfg.TrustWindow <= cfg.Interval { + return nil, fmt.Errorf("chart: a trust window of %s must be longer than the interval of %s, "+ + "or a single slow answer would stop every position seeing through the chart", cfg.TrustWindow, cfg.Interval) + } + if cfg.Now == nil { + cfg.Now = time.Now + } + + return &Keeper{ + src: src, + st: st, + cfg: cfg, + wake: make(chan string, 64), + confirmed: map[string]time.Time{}, + extra: map[string]bool{}, + }, nil +} + +// TrustedSince answers the moment before which a confirmed copy lifts nothing: +// the chart is read only while its tenant's copy was confirmed at or after it. +// The service passes it to wherever it reads the copy. +func (k *Keeper) TrustedSince() time.Time { + return k.cfg.Now().Add(-k.cfg.TrustWindow) +} + +// Wake asks for the tenant's copy now, rather than at the next cycle: a request +// arrived from a tenant this service holds no copy for yet. It never blocks. +func (k *Keeper) Wake(tenant string) { + if tenant == "" { + return + } + select { + case k.wake <- tenant: + default: // a full queue is already a cycle about to run + } +} + +// Run refreshes every tenant's copy once at start and then every interval, and +// any woken tenant at once, until ctx ends. +func (k *Keeper) Run(ctx context.Context) error { + k.cycle(ctx) + + tick := time.NewTicker(k.cfg.Interval) + defer tick.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-tick.C: + k.cycle(ctx) + case tenant := <-k.wake: + k.mu.Lock() + k.extra[tenant] = true + k.mu.Unlock() + k.fail(tenant, k.Sync(ctx, tenant)) + } + } +} + +// cycle refreshes every tenant the store lists and every tenant woken so far. +func (k *Keeper) cycle(ctx context.Context) { + defer func() { + k.mu.Lock() + k.started = true + k.mu.Unlock() + }() + + tenants, err := k.st.Tenants(ctx) + if err != nil { + k.fail("", fmt.Errorf("chart: list the tenants: %w", err)) + + return + } + + k.mu.Lock() + for _, t := range tenants { + delete(k.extra, t) + } + for t := range k.extra { + tenants = append(tenants, t) + } + k.mu.Unlock() + + for _, t := range tenants { + if ctx.Err() != nil { + return + } + k.fail(t, k.Sync(ctx, t)) + } +} + +func (k *Keeper) fail(tenant string, err error) { + if err != nil && k.cfg.OnError != nil { + k.cfg.OnError(tenant, err) + } +} + +// Sync makes one tenant's copy current. A failure to reach the register keeps the +// copy as it was. +func (k *Keeper) Sync(ctx context.Context, tenant string) error { + version, err := k.st.Version(ctx, tenant) + if err != nil { + return fmt.Errorf("chart: read the copy's version: %w", err) + } + + c, changed, err := k.src.Chart(ctx, tenant, version) + if err != nil { + return fmt.Errorf("chart: read the tenant's chart: %w", err) + } + + at := k.cfg.Now() + if changed { + err = k.st.Replace(ctx, tenant, c, at) + } else { + err = k.st.Confirm(ctx, tenant, at) + } + if err != nil { + return fmt.Errorf("chart: keep the copy: %w", err) + } + + k.mu.Lock() + k.confirmed[tenant] = at + k.mu.Unlock() + + return nil +} + +// Ready answers nil once the first cycle has run and while every tenant this +// process has seen holds a copy confirmed inside the trust window, and names +// those that do not. A service reports it on its readiness check: the chart lifts +// nothing there until the register is reached again. +func (k *Keeper) Ready() error { + since := k.TrustedSince() + + k.mu.Lock() + defer k.mu.Unlock() + + if !k.started { + return errors.New("chart: the tenants' charts have not been read yet") + } + + var stale []string + for t, at := range k.confirmed { + if at.Before(since) { + stale = append(stale, t) + } + } + for t := range k.extra { + if _, ok := k.confirmed[t]; !ok { + stale = append(stale, t) + } + } + if len(stale) == 0 { + return nil + } + slices.Sort(stale) + + return fmt.Errorf("chart: the charts of %d tenant(s) are not confirmed: %s", + len(stale), strings.Join(stale, ", ")) +} diff --git a/chart/chart_test.go b/chart/chart_test.go new file mode 100644 index 0000000..fee7918 --- /dev/null +++ b/chart/chart_test.go @@ -0,0 +1,289 @@ +package chart + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/go-quicktest/qt" +) + +// fakeSource is the register: one chart per tenant, and every call recorded. +type fakeSource struct { + mu sync.Mutex + charts map[string]Chart + fail error + asked []string // "tenant@version" per Chart call + called chan string +} + +func (f *fakeSource) Chart(_ context.Context, tenant, version string) (Chart, bool, error) { + f.mu.Lock() + defer func() { + f.mu.Unlock() + if f.called != nil { + f.called <- tenant + } + }() + f.asked = append(f.asked, tenant+"@"+version) + if f.fail != nil { + return Chart{}, false, f.fail + } + c := f.charts[tenant] + if version != "" && version == c.Version { + return Chart{}, false, nil + } + + return c, true, nil +} + +// fakeStore keeps copies in memory. +type fakeStore struct { + mu sync.Mutex + tenants []string + copies map[string]Chart + confirmed map[string]time.Time + replaced int +} + +func newStore(tenants ...string) *fakeStore { + return &fakeStore{tenants: tenants, copies: map[string]Chart{}, confirmed: map[string]time.Time{}} +} + +func (s *fakeStore) Tenants(context.Context) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]string(nil), s.tenants...), nil +} + +func (s *fakeStore) Version(_ context.Context, tenant string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.copies[tenant].Version, nil +} + +func (s *fakeStore) Replace(_ context.Context, tenant string, c Chart, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.copies[tenant] = c + s.confirmed[tenant] = at + s.replaced++ + + return nil +} + +func (s *fakeStore) Confirm(_ context.Context, tenant string, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.confirmed[tenant] = at + + return nil +} + +// clock is a settable time. +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.t +} + +func (c *clock) add(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func start() *clock { return &clock{t: time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)} } + +func tree(version string, below map[string][]string) Chart { + return Chart{Version: version, Below: below} +} + +func keeper(t *testing.T, src Source, st Store, c *clock) *Keeper { + t.Helper() + k, err := New(src, st, Config{Interval: time.Second, TrustWindow: time.Minute, Now: c.now}) + qt.Assert(t, qt.IsNil(err)) + + return k +} + +func TestNewRefusesWhatCannotWork(t *testing.T) { + st, src := newStore(), &fakeSource{} + + _, err := New(nil, st, Config{}) + qt.Check(t, qt.ErrorMatches(err, `.*a source and a store are required`)) + _, err = New(src, nil, Config{}) + qt.Check(t, qt.ErrorMatches(err, `.*a source and a store are required`)) + _, err = New(src, st, Config{Interval: -time.Second}) + qt.Check(t, qt.ErrorMatches(err, `.*cannot be negative`)) + _, err = New(src, st, Config{Interval: time.Minute, TrustWindow: time.Minute}) + qt.Check(t, qt.ErrorMatches(err, `.*must be longer than the interval.*`)) + + k, err := New(src, st, Config{}) + qt.Assert(t, qt.IsNil(err)) + qt.Check(t, qt.Equals(k.cfg.Interval, DefaultInterval)) + qt.Check(t, qt.Equals(k.cfg.TrustWindow, DefaultTrustWindow)) +} + +func TestSyncCopiesTheChart(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"t1": tree(`"v1"`, map[string][]string{"sub:a": {"sub:b"}})}} + st := newStore("t1") + k := keeper(t, src, st, c) + + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + + qt.Check(t, qt.DeepEquals(src.asked, []string{"t1@"})) + qt.Check(t, qt.DeepEquals(st.copies["t1"].Below, map[string][]string{"sub:a": {"sub:b"}})) + qt.Check(t, qt.Equals(st.confirmed["t1"], c.now())) + qt.Check(t, qt.Equals(st.replaced, 1)) +} + +func TestAnUnchangedAnswerOnlyMovesTheConfirmation(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"t1": tree(`"v1"`, nil)}} + st := newStore("t1") + k := keeper(t, src, st, c) + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + + c.add(10 * time.Second) + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + + qt.Check(t, qt.DeepEquals(src.asked, []string{"t1@", `t1@"v1"`})) + qt.Check(t, qt.Equals(st.replaced, 1)) + qt.Check(t, qt.Equals(st.confirmed["t1"], c.now())) +} + +// A move in the chart replaces the copy whole, and a chart emptied is a copy with +// nobody below anyone. +func TestAMoveReplacesTheCopy(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"t1": tree(`"v1"`, map[string][]string{"sub:a": {"sub:b", "sub:c"}})}} + st := newStore("t1") + k := keeper(t, src, st, c) + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + + src.charts["t1"] = tree(`"v2"`, map[string][]string{"sub:a": {"sub:b"}}) + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + qt.Check(t, qt.Equals(st.replaced, 2)) + qt.Check(t, qt.DeepEquals(st.copies["t1"].Below["sub:a"], []string{"sub:b"})) + + src.charts["t1"] = tree(`"v3"`, nil) + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + qt.Check(t, qt.HasLen(st.copies["t1"].Below, 0)) +} + +func TestAnUnreachableRegisterKeepsTheCopyAndStopsTrustingItAfterTheWindow(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"t1": tree(`"v1"`, map[string][]string{"sub:a": {"sub:b"}})}} + st := newStore("t1") + k := keeper(t, src, st, c) + k.cycle(context.Background()) + confirmedAt := c.now() + qt.Assert(t, qt.IsNil(k.Ready())) + + src.fail = errors.New("connection refused") + c.add(30 * time.Second) + qt.Check(t, qt.ErrorMatches(k.Sync(context.Background(), "t1"), `.*connection refused`)) + qt.Check(t, qt.Equals(st.copies["t1"].Version, `"v1"`)) + qt.Check(t, qt.Equals(st.confirmed["t1"], confirmedAt)) + qt.Check(t, qt.IsNil(k.Ready())) // still inside the window + qt.Check(t, qt.IsFalse(confirmedAt.Before(k.TrustedSince()))) + + c.add(31 * time.Second) + qt.Check(t, qt.IsTrue(confirmedAt.Before(k.TrustedSince()))) + qt.Check(t, qt.ErrorMatches(k.Ready(), `.*1 tenant\(s\) are not confirmed: t1`)) + + src.fail = nil + qt.Assert(t, qt.IsNil(k.Sync(context.Background(), "t1"))) + qt.Check(t, qt.IsNil(k.Ready())) +} + +func TestReadyWaitsForTheFirstCycle(t *testing.T) { + k := keeper(t, &fakeSource{charts: map[string]Chart{}}, newStore(), start()) + + qt.Check(t, qt.ErrorMatches(k.Ready(), `.*have not been read yet`)) + k.cycle(context.Background()) + qt.Check(t, qt.IsNil(k.Ready())) +} + +func TestTrustedSinceIsTheWindowBeforeNow(t *testing.T) { + c := start() + k := keeper(t, &fakeSource{}, newStore(), c) + + qt.Check(t, qt.Equals(k.TrustedSince(), c.now().Add(-time.Minute))) +} + +func TestAFailedCycleIsToldAndTheOthersStillRun(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"t1": tree(`"v1"`, nil), "t2": tree(`"v9"`, nil)}} + st := newStore("t0", "t1", "t2") + src.charts["t0"] = tree(`"v0"`, nil) + + var mu sync.Mutex + var failed []string + k, err := New(src, st, Config{Interval: time.Second, TrustWindow: time.Minute, Now: c.now, + OnError: func(tenant string, _ error) { + mu.Lock() + failed = append(failed, tenant) + mu.Unlock() + }}) + qt.Assert(t, qt.IsNil(err)) + + k.cycle(context.Background()) + qt.Check(t, qt.HasLen(failed, 0)) + qt.Check(t, qt.Equals(st.copies["t2"].Version, `"v9"`)) + + src.fail = errors.New("down") + k.cycle(context.Background()) + qt.Check(t, qt.DeepEquals(failed, []string{"t0", "t1", "t2"})) +} + +func TestWakeReadsANewTenantAtOnce(t *testing.T) { + c := start() + src := &fakeSource{charts: map[string]Chart{"new": tree(`"v1"`, nil)}, called: make(chan string, 8)} + st := newStore() + k, err := New(src, st, Config{Interval: time.Hour, TrustWindow: 2 * time.Hour, Now: c.now}) + qt.Assert(t, qt.IsNil(err)) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- k.Run(ctx) }() + + k.Wake("new") + select { + case got := <-src.called: + qt.Check(t, qt.Equals(got, "new")) + case <-time.After(5 * time.Second): + t.Fatal("the woken tenant was not read") + } + + deadline := time.Now().Add(5 * time.Second) + for { + st.mu.Lock() + v := st.copies["new"].Version + st.mu.Unlock() + if v == `"v1"` || time.Now().After(deadline) { + qt.Check(t, qt.Equals(v, `"v1"`)) + + break + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + qt.Check(t, qt.IsNil(<-done)) + qt.Check(t, qt.IsNil(k.Ready())) + + k.Wake("") // an empty tenant is ignored rather than queued +} diff --git a/chart/register.go b/chart/register.go new file mode 100644 index 0000000..28719e6 --- /dev/null +++ b/chart/register.go @@ -0,0 +1,96 @@ +package chart + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/gmb-lib/go-authbyte/authclient" + "github.com/gmb-lib/go-authbyte/placement" +) + +// ScopeChart is the level the membership register checks to answer a tenant's +// chart: reading who is below whom is a different authority from reading roles. +const ScopeChart = "membership:chart" + +// Register is the membership register reached over the network: the [Source] a +// service uses in production. +type Register struct { + do placement.Doer + base string + audience string +} + +// NewRegister answers a Source over the register at baseURL (its root, without +// the API path), reached as the service itself for audience. +func NewRegister(do placement.Doer, baseURL, audience string) (*Register, error) { + if do == nil { + return nil, errors.New("chart: a client is required") + } + u, err := url.Parse(baseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("chart: %q is not an absolute address", baseURL) + } + if audience == "" { + return nil, errors.New("chart: the register's audience is required") + } + + return &Register{do: do, base: strings.TrimRight(baseURL, "/"), audience: audience}, nil +} + +// chartBody is the register's answer: for each person, everyone below them. +type chartBody struct { + Below map[string][]string `json:"below"` +} + +// Chart implements Source. +func (r *Register) Chart(ctx context.Context, tenant, version string) (Chart, bool, error) { + h := http.Header{"Accept": {"application/json"}} + if version != "" { + h.Set("If-None-Match", version) + } + + res, err := r.do.DoServiceForTenant(ctx, r.audience, ScopeChart, tenant, http.MethodGet, + r.base+"/api/v1/tenants/"+url.PathEscape(tenant)+"/chart", h, nil) + if err != nil { + return Chart{}, false, err + } + + switch res.StatusCode { + case http.StatusNotModified: + return Chart{}, false, nil + case http.StatusOK: + default: + return Chart{}, false, &authclient.Error{Hop: authclient.HopResource, Status: res.StatusCode, Body: string(res.Body)} + } + + etag := res.Header.Get("ETag") + if etag == "" { + return Chart{}, false, errors.New("chart: the register answered the chart without a version") + } + + var body chartBody + if err := json.Unmarshal(res.Body, &body); err != nil { + return Chart{}, false, fmt.Errorf("chart: read the chart: %w", err) + } + + c := Chart{Version: etag, Below: make(map[string][]string, len(body.Below))} + for subject, below := range body.Below { + if subject == "" { + return Chart{}, false, errors.New("chart: the register answered a position holder with no subject") + } + below = slices.DeleteFunc(slices.Clone(below), func(s string) bool { return s == "" || s == subject }) + slices.Sort(below) + below = slices.Compact(below) + if len(below) > 0 { + c.Below[subject] = below + } + } + + return c, true, nil +} diff --git a/chart/register_test.go b/chart/register_test.go new file mode 100644 index 0000000..03639cb --- /dev/null +++ b/chart/register_test.go @@ -0,0 +1,116 @@ +package chart + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/go-quicktest/qt" + + "github.com/gmb-lib/go-authbyte/authclient" + "github.com/gmb-lib/go-authbyte/placement" +) + +// fakeDoer answers one prepared response and records the call. +type fakeDoer struct { + res *authclient.BackgroundResponse + err error + + audience, scope, tenant, method, url string + header http.Header +} + +func (f *fakeDoer) DoServiceForTenant(_ context.Context, audience, scope, tenant, method, fullURL string, + h http.Header, _ []byte, +) (*authclient.BackgroundResponse, error) { + f.audience, f.scope, f.tenant, f.method, f.url, f.header = audience, scope, tenant, method, fullURL, h + + return f.res, f.err +} + +func register(t *testing.T, d placement.Doer) *Register { + t.Helper() + r, err := NewRegister(d, "http://membership:8080/", "membership") + qt.Assert(t, qt.IsNil(err)) + + return r +} + +func ok(body string) *authclient.BackgroundResponse { + return &authclient.BackgroundResponse{StatusCode: http.StatusOK, Header: http.Header{"Etag": {`"v1"`}}, Body: []byte(body)} +} + +func TestNewRegisterRefusesWhatCannotWork(t *testing.T) { + d := &fakeDoer{} + _, err := NewRegister(nil, "http://r", "a") + qt.Check(t, qt.ErrorMatches(err, `.*a client is required`)) + _, err = NewRegister(d, "membership", "a") + qt.Check(t, qt.ErrorMatches(err, `.*not an absolute address`)) + _, err = NewRegister(d, "http://r", "") + qt.Check(t, qt.ErrorMatches(err, `.*audience is required`)) +} + +func TestChartIsAskedAsTheServiceForTheTenant(t *testing.T) { + d := &fakeDoer{res: ok(`{"below":{}}`)} + + _, _, err := register(t, d).Chart(context.Background(), "t 1", `"v0"`) + + qt.Assert(t, qt.IsNil(err)) + qt.Check(t, qt.Equals(d.audience, "membership")) + qt.Check(t, qt.Equals(d.scope, ScopeChart)) + qt.Check(t, qt.Equals(d.tenant, "t 1")) + qt.Check(t, qt.Equals(d.method, http.MethodGet)) + qt.Check(t, qt.Equals(d.url, "http://membership:8080/api/v1/tenants/t%201/chart")) + qt.Check(t, qt.Equals(d.header.Get("If-None-Match"), `"v0"`)) +} + +func TestChartReadsWhoIsBelowWhomCleanedAndSorted(t *testing.T) { + d := &fakeDoer{res: ok(`{"below":{"sub:a":["sub:c","sub:b","sub:c","","sub:a"],"sub:b":[],"sub:c":["sub:d"]}}`)} + + got, changed, err := register(t, d).Chart(context.Background(), "t1", "") + + qt.Assert(t, qt.IsNil(err)) + qt.Check(t, qt.IsTrue(changed)) + qt.Check(t, qt.Equals(got.Version, `"v1"`)) + // A person is never below themselves, a repeat counts once, and a person with + // nobody below has no entry. + qt.Check(t, qt.DeepEquals(got.Below, map[string][]string{"sub:a": {"sub:b", "sub:c"}, "sub:c": {"sub:d"}})) +} + +func TestChartReadsA304AsUnchanged(t *testing.T) { + d := &fakeDoer{res: &authclient.BackgroundResponse{StatusCode: http.StatusNotModified}} + + _, changed, err := register(t, d).Chart(context.Background(), "t1", `"abc"`) + + qt.Assert(t, qt.IsNil(err)) + qt.Check(t, qt.IsFalse(changed)) +} + +func TestChartRefusedIsTyped(t *testing.T) { + d := &fakeDoer{res: &authclient.BackgroundResponse{StatusCode: http.StatusForbidden, Body: []byte(`{"title":"no"}`)}} + + _, _, err := register(t, d).Chart(context.Background(), "t1", "") + + var refusedErr *authclient.Error + qt.Assert(t, qt.IsTrue(errors.As(err, &refusedErr))) + qt.Check(t, qt.Equals(refusedErr.Status, http.StatusForbidden)) +} + +func TestChartWithoutAVersionOrWithABadBodyFails(t *testing.T) { + noTag := &fakeDoer{res: &authclient.BackgroundResponse{StatusCode: http.StatusOK, Body: []byte(`{"below":{}}`)}} + _, _, err := register(t, noTag).Chart(context.Background(), "t1", "") + qt.Check(t, qt.ErrorMatches(err, `.*without a version`)) + + bad := &fakeDoer{res: ok(`not json`)} + _, _, err = register(t, bad).Chart(context.Background(), "t1", "") + qt.Check(t, qt.ErrorMatches(err, `.*read the chart.*`)) + + noSubject := &fakeDoer{res: ok(`{"below":{"":["sub:b"]}}`)} + _, _, err = register(t, noSubject).Chart(context.Background(), "t1", "") + qt.Check(t, qt.ErrorMatches(err, `.*no subject`)) + + down := &fakeDoer{err: errors.New("dial tcp: refused")} + _, _, err = register(t, down).Chart(context.Background(), "t1", "") + qt.Check(t, qt.ErrorMatches(err, `dial tcp: refused`)) +} diff --git a/permissions/permissions.go b/permissions/permissions.go index 88f5bbc..9819816 100644 --- a/permissions/permissions.go +++ b/permissions/permissions.go @@ -66,6 +66,13 @@ const ( // Object is a permission granted on one object the service owns, such as // commenting on the tasks of one project. Object Plane = "object" + // Chart is a permission granted to a position in the tenant's chart of + // authority and held relative to the tree: it lifts what the people below the + // holder already see, such as the projects of everyone who reports to them. It + // is never ticked on a role or a user type and never seeds a role. It is + // ordinary in class, and it is checked in the service's reads, where a viewer's + // reach is added to their own sight, rather than on a route. + Chart Plane = "chart" ) // Permission is one act a service enforces on one of its features. @@ -202,10 +209,12 @@ func check(service string, p Permission) error { case p.Class != Ordinary && p.Class != TenantConfiguration && p.Class != RoleManagement && p.Class != PerField: return fmt.Errorf("permissions: %s: class %q is not ordinary, tenantConfiguration, roleManagement or perField", name, p.Class) - case p.Plane != Tenant && p.Plane != Object: - return fmt.Errorf("permissions: %s: plane %q is not tenant or object", name, p.Plane) + case p.Plane != Tenant && p.Plane != Object && p.Plane != Chart: + return fmt.Errorf("permissions: %s: plane %q is not tenant, object or chart", name, p.Plane) case p.Class == PerField && p.Plane != Object: return fmt.Errorf("permissions: %s: a per-field family is held where a role is placed, on the object plane", name) + case p.Plane == Chart && p.Class != Ordinary: + return fmt.Errorf("permissions: %s: a permission held by a position in the chart is ordinary in class", name) } if len(p.Seeds) > 0 && (p.Plane != Object || p.Class != Ordinary) { return fmt.Errorf("permissions: %s: only an ordinary permission on the object plane is given to a seeded role", name) diff --git a/permissions/permissions_test.go b/permissions/permissions_test.go index 025e90b..11b15be 100644 --- a/permissions/permissions_test.go +++ b/permissions/permissions_test.go @@ -52,6 +52,9 @@ func TestNewRefusesWhatTheRegisterRefuses(t *testing.T) { {"a seed named twice", with(func(p *Permission) { p.Seeds = []string{"worker", "worker"} }), "named twice"}, {"a per-field family on the tenant plane", with(func(p *Permission) { p.Class = PerField; p.Plane = Tenant }), "object plane"}, {"a seed on a per-field family", with(func(p *Permission) { p.Class = PerField; p.Seeds = []string{"manager"} }), "seeded role"}, + {"a seed on a chart permission", with(func(p *Permission) { p.Plane = Chart; p.Seeds = []string{"manager"} }), "seeded role"}, + {"a setup permission on the chart plane", with(func(p *Permission) { p.Plane = Chart; p.Class = TenantConfiguration }), "ordinary in class"}, + {"a per-field family on the chart plane", with(func(p *Permission) { p.Plane = Chart; p.Class = PerField }), "object plane"}, } { t.Run(tc.name, func(t *testing.T) { _, err := New("projects", "Projects", tc.list) @@ -214,6 +217,28 @@ func TestAPerFieldFamilyIsDeclaredOnceOnTheObjectPlane(t *testing.T) { qt.Check(t, qt.IsTrue(viewField.HeldBy(scopes{"projects/task:viewField": true}))) } +// A permission held by a position in a chart is declared on the chart plane and +// travels to the register as such, under a key of its own. +func TestAChartPermissionIsDeclaredOnTheChartPlane(t *testing.T) { + box := Permission{Feature: "project", Act: "view", Description: "See the projects of the people below you", + Class: Ordinary, Plane: Chart} + s, err := New("authority", "Chart of authority", []Permission{box}) + qt.Assert(t, qt.IsNil(err)) + + raw, err := s.Section() + qt.Assert(t, qt.IsNil(err)) + var doc struct { + Services []struct { + Key string `json:"key"` + Permissions []map[string]any `json:"permissions"` + } `json:"services"` + } + qt.Assert(t, qt.IsNil(json.Unmarshal(raw, &doc))) + qt.Check(t, qt.Equals(doc.Services[0].Key, "authority")) + qt.Check(t, qt.Equals(doc.Services[0].Permissions[0]["plane"], any("chart"))) + qt.Check(t, qt.IsTrue(s.Declared("project", "view").HeldBy(scopes{"authority/project:view": true}))) +} + // What the Set answers is its own copy: changing it changes nothing declared. func TestListAndLookupAnswerCopies(t *testing.T) { p := valid("task", "view") diff --git a/permissions/permissionstest/permissionstest.go b/permissions/permissionstest/permissionstest.go index bf85c8f..35a0d62 100644 --- a/permissions/permissionstest/permissionstest.go +++ b/permissions/permissionstest/permissionstest.go @@ -34,9 +34,10 @@ func Check(t testing.TB, set *permissions.Set, gates ...*permissions.Gate) { } // DeclaredIsChecked fails unless the permissions the routes accept are exactly -// the Set's, retired ones and per-field families aside: either may still be -// accepted and need not be. A retired permission keeps working for the roles that -// hold it; a per-field family is checked on each value the service answers. +// the Set's, retired ones, per-field families and chart permissions aside: any may +// still be accepted and need not be. A retired permission keeps working for the +// roles that hold it; a per-field family is checked on each value the service +// answers; a chart permission is read from the viewer's reach, not from a route. func DeclaredIsChecked(t testing.TB, set *permissions.Set, gates ...*permissions.Gate) { t.Helper() accepted := map[string]bool{} @@ -49,7 +50,7 @@ func DeclaredIsChecked(t testing.TB, set *permissions.Set, gates ...*permissions for _, p := range set.List() { name := p.Name(set.Service()) declared[name] = true - if !p.Retired && p.Class != permissions.PerField && !accepted[name] { + if !p.Retired && p.Class != permissions.PerField && p.Plane != permissions.Chart && !accepted[name] { t.Errorf("%s is declared but no route accepts it", name) } } diff --git a/permissions/permissionstest/permissionstest_test.go b/permissions/permissionstest/permissionstest_test.go index aaebbdd..01235d1 100644 --- a/permissions/permissionstest/permissionstest_test.go +++ b/permissions/permissionstest/permissionstest_test.go @@ -67,15 +67,17 @@ func TestCheckPassesAServiceThatKeepsEveryRule(t *testing.T) { qt.Check(t, qt.HasLen(report(t, func(tb testing.TB) { Check(tb, s, g) }), 0)) } -// A declared permission no route accepts fails, and a retired one or a per-field -// family does not. +// A declared permission no route accepts fails, and a retired one, a per-field +// family or a chart permission does not. func TestDeclaredButUncheckedFails(t *testing.T) { retired := perm("task", "archive") retired.Retired = true family := perm("task", "viewField") family.Class = permissions.PerField + reach := perm("task", "see") + reach.Plane = permissions.Chart s := permissions.MustNew("projects", "Projects", []permissions.Permission{ - perm("task", "view"), perm("task", "edit"), retired, family, + perm("task", "view"), perm("task", "edit"), retired, family, reach, }) g := s.Gate(nil) g.OneOf(permissions.Level{}, ok, s.Declared("task", "view"))