feat(natsstore): serve all tenants from a single muxed K/V bucket - #4
Conversation
Replace the bucket-per-tenant model with one muxed bucket (config.bucket), keyed "<tenant>.<key...>". The Rego interface is unchanged: data still lands at data.nats.kv.<tenant>.<...> and the builtins (nats.kv.watch_bucket / nats.kv.get_data) keep their names and arity — their first argument is now the tenant token (the leading key segment) instead of a bucket name. Core changes: - config: add required `bucket`; rename `root_bucket` -> `root_tenant` (a tenant whose subtree mounts at the OPA data root). - NATSKeyToOPAPath: derive the tenant from the key's first token (drop the out-of-band bucket arg); root mode strips the tenant token. - nats_client: open the single bucket once (cached); add tenantKeys(), a prefix-filtered lister so loads are O(tenant), never O(whole bucket). - watcher: watch "<tenant>.>" (single filter -> scopeable consumer) instead of ">"; identity is the tenant. - builtins: loadTenantAsGJSON reads only the tenant slice and strips the "<tenant>." prefix via the new pure buildTenantJSON helper. Tests (TDD for the pure logic): - reshaped path-mapping/config/JSON-builder unit tests; - new muxed_integration_test.go (real NATS): tenantKeys isolation, data placement, root-tenant stripping; - migrated the example (one DATA bucket, <tenant>.* keys) + configs; the docker-compose integration test passes end to end. No backward compatibility retained (intentional). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le bucket to POLICY_DATA The muxed bucket name is required configuration for the reader — there must be no implicit default that could silently point at the wrong bucket. Remove the "DATA" default from DefaultConfig (Validate still enforces "bucket is required") and set the example/deployment bucket to POLICY_DATA to match the data-generator producer (NATS_KV_BUCKET=POLICY_DATA). - config: DefaultConfig no longer sets Bucket (mandatory, explicit-only). - example configs + docker-compose seeding + account perms: DATA -> POLICY_DATA. - README: document `bucket` as required; fix the stale root_bucket -> root_tenant row. - tests: set Bucket explicitly where DefaultConfig is validated; assert no default. Verified: pkg suite + cmd docker-compose integration green; golangci-lint 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- tenantKeys now takes a context.Context, binds the watch with nats.Context(ctx), selects on ctx.Done(), and treats a watch channel that closes BEFORE the nil initial-values marker as an error instead of silently returning a truncated key set (partial tenant data can flip an authz decision). ctx is threaded through loadTenantAsGJSON / LoadBucketDataBulk / the builtins. - buildTenantJSON: a failed Get now returns nil and is SKIPPED (not written as an explicit null); the sjson error is handled; non-JSON stored values fall back to a JSON string, mirroring loadSingleKey so the read and bulk-load paths agree. - NATSClient drops the cached bucket handle on reconnect so it is re-opened lazily. - validateTenant rejects tenant ids that aren't a single safe NATS subject token (empty / '.' / '*' / '>' / whitespace) at tenantKeys and the watcher Start, so the subject-token watch and the string-prefix read paths cannot diverge. - Removed the dead DataTransformer.rootBucket field and the now-unused config param from NewDataTransformer. - Gated muxed_integration_test.go behind //go:build integration; switched the connect-dependent plugin tests to require.* so they fail cleanly instead of nil-deref-panicking when NATS is unreachable. Addresses review comments: - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bucket sync - example rego: clarify that non-root tenants land at data.nats.kv.<tenant>.<...> while the configured root_tenant mounts at the data root with its token stripped. - README account perms: note that $JS.API.> is account-wide JetStream access and that the bucket name must stay in sync with the $KV.<bucket>.> subjects. Addresses review comments: - #4 (comment) (@zeevmoney) - #4 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The documented build is `go build -o opa ./cmd/opa-nats`, producing `opa`, which wasn't ignored; add it (and keep /opa-nats for the bare build). Drop the leading blank line. Addresses review comments: - #4 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… NATS The examples stack mounted config.yaml (server_url=localhost:4222) into the in-container opa service, so localhost resolved to the OPA container and the plugin couldn't reach the `nats` service. Mount config-compose.yaml (nats://nats:4222) instead and drop the unused NATS_URL env. Addresses review comments: - #4 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Automated Review - the PR looks clean, found nothing.
Code review — informational findings (not inline)These are from a re-review of
All pre-existing except where noted; raised here for visibility, non-blocking. Happy to open a follow-up issue or push fixes for #1 (root |
zeevmoney
left a comment
There was a problem hiding this comment.
Approved, see one comment and note this one:
See if it's safe to merge or defer it to a future issue.
… instead of failing open watch_bucket special-cased nats.ErrBucketNotFound and returned false. With the single muxed bucket an absent tenant yields zero keys (handled separately by loadTenantAsGJSON returning a Null result, no error), so ErrBucketNotFound here can only mean the configured bucket is missing — a deployment misconfiguration. Returning false silently evaluates policy against missing data; surface the error instead, consistent with get_data. Removed the now-unused errors/nats imports. Addresses review comments: - #4 (comment) (@zeevmoney) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What & why
Replaces the bucket-per-tenant model with a single muxed bucket (
config.bucket), keyed"<tenant>.<key...>". At ~40k tenants, one KV bucket per tenant means one JetStream RAFT group per tenant (~40k HA assets, >10GiB RAM, ~20× over the ~2k-HA-assets/server budget). Collapsing to one bucket removes that fixed per-RAFT-group cost.The Rego interface is unchanged — data still lands at
data.nats.kv.<tenant>.<...>, andnats.kv.watch_bucket/nats.kv.get_datakeep their names and arity. The only conceptual shift: argument 1 is now the tenant token (the leading key segment in the one bucket) instead of a bucket name. Policies don't change.Core changes
bucket; renameroot_bucket→root_tenant(a tenant whose subtree mounts at the OPA data root).NATSKeyToOPAPath: derive the tenant from the key's first token (drop the out-of-band bucket arg); root mode strips the tenant token.nats_client: open the single bucket once (cached); addtenantKeys(), a prefix-filtered lister so loads areO(tenant), neverO(whole bucket)— the key correctness/perf change."<tenant>.>"(single filter → scopeable consumer) instead of">"; identity is the tenant.loadTenantAsGJSONreads only the tenant slice and strips the"<tenant>."prefix via the new purebuildTenantJSONhelper.Tests (TDD for the pure logic)
muxed_integration_test.go(real NATS):tenantKeysisolation, data placement atdata.nats.kv.<tenant>, t2 never leaking into a t1 load, root-tenant stripping.DATAbucket,<tenant>.*keys) + both config files + README; account perms scoped to$KV.DATA.>.Verification
pkg/natsstoresuite: ok (incl. the new real-NATS integration test).cmd/opa-natsdocker-compose integration test: PASS (54s) — real OPA + NATS,bucket_watchedflips false→true,get_data(bucket,"members")returns the members map,data.nats.kv[bucket_id]returns the full tenant tree. Same interface, same placement.go build,go vet,gofmt, golangci-lint (0 issues), all pre-commit hooks pass.Operational note
At the cloud, each watched tenant is one ordered consumer on
KV_DATA, capped bymax_bucket_watchers(LRU).WatchFilteredcould batch tenants later if fan-in becomes a concern.🤖 Generated with Claude Code