package membership // Per-subject data-plane access control derived from room membership (issue // 0003e, audit H4 residual; tightened in issue 0006b for audit 0008 N2). 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 — and can // only reach the JetStream API of ITS OWN rooms' streams, never the control-plane // KV buckets. import ( "encoding/hex" "fmt" "strings" "github.com/enmanuel/unibus/pkg/frame" ) // clientInfraSubjects are the subjects every authorized peer needs regardless of // room membership, kept deliberately MINIMAL (issue 0006b, audit 0008 N2): // // - "_INBOX.>" — request/reply plus the JetStream pull-consumer delivery // and publish-ack inboxes. // - "$JS.API.INFO" — account-level JetStream info (limits/usage counters). It // exposes NO room/user/key contents, so granting it leaks nothing. // // It NO LONGER contains "$JS.API.>". That broad grant was the N2 leak: it let any // registered peer drive the whole JetStream API and read the control-plane KV // buckets (KV_UNIBUS_users/rooms/members/room_keys) and the object store directly // over NATS, bypassing the HTTP authorization (requireMember and the own-endpoint // checks). JetStream API access is now granted PER ROOM, scoped to the stream of // each room the peer belongs to (jsSubjectsFor). Because the control-plane KV // streams (KV_UNIBUS_*) and the object store (OBJ_UNIBUS_*) are never a room // stream, they fall outside the closed allow set and are denied by default. var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.INFO"} // roomStreamName is the JetStream stream name a persisted room maps to. It MUST // stay identical to pkg/client.streamName ("UNIBUS_" + sanitized roomID) so the // per-room ACL grants exactly the subjects the client's JetStream calls use. Room // ids are ULIDs (no '.'), so the sanitizing is a no-op in practice, but the rule // is replicated defensively so the producer (client) and the authorizer (this // ACL) never drift apart. func roomStreamName(roomID string) string { var b strings.Builder b.Grow(len("UNIBUS_") + len(roomID)) b.WriteString("UNIBUS_") for _, r := range roomID { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': b.WriteRune(r) default: b.WriteRune('_') } } return b.String() } // jsSubjectsFor returns the MINIMAL JetStream API subjects a peer needs to use the // durable stream of ONE persisted room: create/update/info the stream, manage and // pull from its durable consumer, and ack deliveries. Every subject embeds this // room's stream name, so the grant cannot reach another room's stream nor any // control-plane stream (KV_UNIBUS_* / OBJ_UNIBUS_*). The wildcard layout matches // the NATS JetStream API subject grammar (the stream name is the trailing token // of single-verb requests and follows a two-token verb for MSG.GET / MSG.NEXT / // DURABLE.CREATE): // // $JS.API.STREAM.. verb in {CREATE,UPDATE,INFO,DELETE,PURGE,...} // $JS.API.STREAM.MSG.. op in {GET,DELETE} // $JS.API.CONSUMER.. verb in {LIST,NAMES,CREATE(ephemeral)} // $JS.API.CONSUMER...... verb in {CREATE,INFO,DELETE} // $JS.API.CONSUMER.... {MSG.NEXT, DURABLE.CREATE} // $JS.ACK..> message acknowledgements func jsSubjectsFor(roomID string) []string { s := roomStreamName(roomID) return []string{ "$JS.API.STREAM.*." + s, "$JS.API.STREAM.*.*." + s, "$JS.API.CONSUMER.*." + s, "$JS.API.CONSUMER.*." + s + ".>", "$JS.API.CONSUMER.*.*." + s + ".>", "$JS.ACK." + s + ".>", } } // 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, the per-room JetStream API subjects of // those rooms (so persisted-room history keeps working), plus the minimal 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) } // clientInfra + per room: the room subject + that room's JetStream API. subjects := make([]string, 0, len(clientInfraSubjects)+len(rooms)*7) subjects = append(subjects, clientInfraSubjects...) for _, r := range rooms { subjects = append(subjects, r.Subject) subjects = append(subjects, jsSubjectsFor(r.RoomID)...) } return subjects, nil } }