96abb75a2e
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.
53 lines
2.3 KiB
Go
53 lines
2.3 KiB
Go
package membership
|
|
|
|
// Per-subject data-plane access control derived from room membership (issue
|
|
// 0003e, audit H4 residual). The control plane already authorizes metadata by
|
|
// membership; this is the matching restriction on the NATS data plane so a
|
|
// registered peer can only publish/subscribe on the subjects of the rooms it
|
|
// actually belongs to — not on every subject on the bus.
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
)
|
|
|
|
// clientInfraSubjects are the subjects every peer needs regardless of room
|
|
// membership: the request/reply inbox space and the JetStream API (the durable
|
|
// plane of persisted rooms). They are granted to all authorized peers so
|
|
// request/reply and persisted-room history keep working under the subject ACL.
|
|
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.>"}
|
|
|
|
// SubjectACLFor returns a function that maps a signing public key (lowercase
|
|
// hex) to the data-plane subjects that identity may publish and subscribe to:
|
|
// the subject of every room it belongs to, plus the client infrastructure
|
|
// subjects. It reads the live membership store, so the permissions reflect the
|
|
// identity's rooms at the moment it connects. A decode error or a store failure
|
|
// is returned as an error so the caller can fail closed (deny the connection)
|
|
// rather than grant open access.
|
|
//
|
|
// Because NATS freezes permissions at connect time, a peer invited to a new room
|
|
// after connecting must reconnect (client.RefreshSession) to pick up the new
|
|
// room's subject. The bus is the authoritative directory of subjects, so an
|
|
// unlisted subject is simply absent from the allow set.
|
|
func SubjectACLFor(store Store) func(signPubHex string) ([]string, error) {
|
|
return func(signPubHex string) ([]string, error) {
|
|
pub, err := hex.DecodeString(signPubHex)
|
|
if err != nil || len(pub) != 32 {
|
|
return nil, fmt.Errorf("acl: malformed sign pub %q", signPubHex)
|
|
}
|
|
endpoint := frame.EndpointID(pub)
|
|
rooms, err := store.ListRoomsForEndpoint(endpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acl: list rooms for %s: %w", endpoint, err)
|
|
}
|
|
subjects := make([]string, 0, len(rooms)+len(clientInfraSubjects))
|
|
subjects = append(subjects, clientInfraSubjects...)
|
|
for _, r := range rooms {
|
|
subjects = append(subjects, r.Subject)
|
|
}
|
|
return subjects, nil
|
|
}
|
|
}
|