feat(0003e/3): per-subject data-plane ACL from room membership (audit H4)
Closes the residual the 0004 hardening deferred: the NATS authenticator can now confine a registered peer to the subjects of the rooms it belongs to, instead of letting any registered identity sub/pub on any subject. The dynamic-membership reconnection model the audit named is provided by client.RefreshSession. pkg/busauth: - verifyNkey factors out the shared nkey verification. - NewNkeyAuthenticatorACL + PermissionsFunc: an authenticator that, after authorizing, derives and RegisterUser()s per-subject permissions. A derivation error denies the connection (fail closed). pkg/membership: - SubjectACLFor(store) maps a signing pubkey to the subjects it may use: the subject of every room it belongs to, plus the client infrastructure subjects (_INBOX.>, $JS.API.> for request/reply and the persisted plane). pkg/client: - RefreshSession() rebuilds the data-plane connection so the authenticator re-derives permissions after a membership change (NATS freezes permissions at connect time). It retains the seeds/options to reconnect; active subscriptions are dropped and must be re-made (documented). Tests (DoD: isolation + refresh): - TestSubjectACLIsolation: alice (member of room.A) may sub/pub room.A but is DENIED sub and pub on room.B (permissions violation), and never reads bob's room.B traffic; bob never receives alice's cross-room publish. - TestRefreshSessionGainsNewRoom: alice has no permission for room B until she is added and calls RefreshSession; the reconnect grants the subject and she then receives room B traffic. Scope note: the per-subject ACL authenticator is opt-in (NewServer/ membershipd keep the open authenticator by default) and is wired in with the decentralized boot path; auto-RefreshSession on every membership change (fully transparent) remains for 0003f. Master behavior unchanged.
This commit is contained in:
+46
-8
@@ -54,6 +54,11 @@ type Client struct {
|
||||
ctrlURLs []string // control-plane HTTP endpoints, tried in order (failover)
|
||||
http *http.Client
|
||||
|
||||
// natsServers + natsOpts are retained so RefreshSession can rebuild the
|
||||
// data-plane connection (re-triggering the server's subject-ACL evaluation).
|
||||
natsServers []string
|
||||
natsOpts []nats.Option
|
||||
|
||||
mu sync.RWMutex
|
||||
keyCache map[string]map[int][]byte // roomID -> epoch -> K
|
||||
signCache map[string][]byte // sender endpoint -> sign pub (for verification)
|
||||
@@ -187,17 +192,50 @@ func NewWithOptions(natsURL, ctrlURL string, id cs.Identity, opts Options) (*Cli
|
||||
httpClient.Transport = &http.Transport{TLSClientConfig: opts.CtrlTLS.Clone()}
|
||||
}
|
||||
return &Client{
|
||||
id: id,
|
||||
endpoint: frame.EndpointID(id.SignPub),
|
||||
nc: nc,
|
||||
js: js,
|
||||
ctrlURLs: dedupNonEmpty(append([]string{ctrlURL}, opts.CtrlURLs...)),
|
||||
http: httpClient,
|
||||
keyCache: map[string]map[int][]byte{},
|
||||
signCache: map[string][]byte{},
|
||||
id: id,
|
||||
endpoint: frame.EndpointID(id.SignPub),
|
||||
nc: nc,
|
||||
js: js,
|
||||
ctrlURLs: dedupNonEmpty(append([]string{ctrlURL}, opts.CtrlURLs...)),
|
||||
http: httpClient,
|
||||
natsServers: natsServers,
|
||||
natsOpts: natsOpts,
|
||||
keyCache: map[string]map[int][]byte{},
|
||||
signCache: map[string][]byte{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshSession rebuilds the data-plane NATS connection so the server's
|
||||
// subject-ACL authenticator re-evaluates this peer's room membership (issue
|
||||
// 0003e, audit H4 residual). Call it after a membership change — a room you
|
||||
// created, were invited to, or joined — when the bus enforces per-subject
|
||||
// permissions, so the new room's subject becomes publishable and subscribable
|
||||
// (NATS freezes permissions at connect time, so the prior connection cannot see
|
||||
// the new room).
|
||||
//
|
||||
// It opens a fresh connection with the same seeds/options and swaps it in.
|
||||
// IMPORTANT: active subscriptions from the previous connection are dropped —
|
||||
// re-subscribe (client.Subscribe) to your rooms after calling this. The key and
|
||||
// signer caches are preserved. On a non-ACL bus this is a no-op-safe reconnect.
|
||||
func (c *Client) RefreshSession() error {
|
||||
nc, err := nats.Connect(strings.Join(c.natsServers, ","), c.natsOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: refresh session: reconnect nats: %w", err)
|
||||
}
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return fmt.Errorf("client: refresh session: init jetstream: %w", err)
|
||||
}
|
||||
old := c.nc
|
||||
c.mu.Lock()
|
||||
c.nc = nc
|
||||
c.js = js
|
||||
c.mu.Unlock()
|
||||
old.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Endpoint returns this client's public identity.
|
||||
func (c *Client) Endpoint() Endpoint {
|
||||
return Endpoint{ID: c.endpoint, SignPub: c.id.SignPub, KexPub: c.id.KexPub}
|
||||
|
||||
Reference in New Issue
Block a user