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.
291 lines
8.9 KiB
Go
291 lines
8.9 KiB
Go
package membership_test
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"net"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"github.com/nats-io/nats.go"
|
|
server "github.com/nats-io/nats-server/v2/server"
|
|
)
|
|
|
|
func aclFreePort(t *testing.T) int {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("free port: %v", err)
|
|
}
|
|
defer l.Close()
|
|
return l.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func mustID(t *testing.T) cs.Identity {
|
|
t.Helper()
|
|
id, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("identity: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// aclPermsFunc adapts membership.SubjectACLFor into the busauth.PermissionsFunc
|
|
// the ACL authenticator expects (same Allow set for publish and subscribe).
|
|
func aclPermsFunc(store membership.Store) busauth.PermissionsFunc {
|
|
derive := membership.SubjectACLFor(store)
|
|
return func(signPubHex string) (*server.Permissions, error) {
|
|
subs, err := derive(signPubHex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sp := &server.SubjectPermission{Allow: subs}
|
|
return &server.Permissions{Publish: sp, Subscribe: sp}, nil
|
|
}
|
|
}
|
|
|
|
// startACLNats boots an embedded NATS whose authenticator confines each peer to
|
|
// the subjects of the rooms it belongs to (audit H4 residual).
|
|
func startACLNats(t *testing.T, store membership.Store) *server.Server {
|
|
t.Helper()
|
|
auth := busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, aclPermsFunc(store))
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: aclFreePort(t), Auth: auth,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("acl nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
return ns
|
|
}
|
|
|
|
func nkeyConn(t *testing.T, natsURL string, id cs.Identity, errCh chan error) *nats.Conn {
|
|
t.Helper()
|
|
pub, sign, err := busauth.ClientNkey(id.SignPriv)
|
|
if err != nil {
|
|
t.Fatalf("nkey: %v", err)
|
|
}
|
|
nc, err := nats.Connect(natsURL,
|
|
nats.Nkey(pub, sign),
|
|
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, e error) {
|
|
select {
|
|
case errCh <- e:
|
|
default:
|
|
}
|
|
}),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("connect nkey: %v", err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
return nc
|
|
}
|
|
|
|
func mustAddUser(t *testing.T, store membership.Store, id cs.Identity, handle string) {
|
|
t.Helper()
|
|
if err := store.AddUser(hex.EncodeToString(id.SignPub), handle, membership.RoleMember); err != nil {
|
|
t.Fatalf("add user %s: %v", handle, err)
|
|
}
|
|
}
|
|
|
|
func mustCreateRoom(t *testing.T, store membership.Store, roomID, subject, ownerEP string, owner cs.Identity) {
|
|
t.Helper()
|
|
info := membership.RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: ownerEP}
|
|
if err := store.CreateRoom(info, owner.SignPub, owner.KexPub, nil); err != nil {
|
|
t.Fatalf("create room %s: %v", roomID, err)
|
|
}
|
|
}
|
|
|
|
func newCtrl(t *testing.T, store membership.Store, blobs blobstore.Store) string {
|
|
t.Helper()
|
|
ts := httptest.NewServer(membership.NewServer(store, blobs, membership.AuthOff))
|
|
t.Cleanup(ts.Close)
|
|
return ts.URL
|
|
}
|
|
|
|
func waitErr(ch chan error, d time.Duration) error {
|
|
select {
|
|
case e := <-ch:
|
|
return e
|
|
case <-time.After(d):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func drain(ch chan error) {
|
|
for {
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSubjectACLIsolation closes the audit H4 residual: a registered peer is
|
|
// confined to the subjects of the rooms it belongs to. alice (member of room.A)
|
|
// may sub/pub room.A but is DENIED sub/pub on room.B, and never reads what bob
|
|
// (member of room.B) publishes there.
|
|
func TestSubjectACLIsolation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
alice, bob := mustID(t), mustID(t)
|
|
aliceEP, bobEP := frame.EndpointID(alice.SignPub), frame.EndpointID(bob.SignPub)
|
|
mustAddUser(t, store, alice, "alice")
|
|
mustAddUser(t, store, bob, "bob")
|
|
const subjA, subjB = "room.acl.a", "room.acl.b"
|
|
mustCreateRoom(t, store, "ROOMA", subjA, aliceEP, alice)
|
|
mustCreateRoom(t, store, "ROOMB", subjB, bobEP, bob)
|
|
|
|
srv := startACLNats(t, store)
|
|
url := srv.ClientURL()
|
|
aliceErr := make(chan error, 4)
|
|
bobErr := make(chan error, 4)
|
|
aliceNC := nkeyConn(t, url, alice, aliceErr)
|
|
bobNC := nkeyConn(t, url, bob, bobErr)
|
|
|
|
// alice may subscribe to her own room (no error).
|
|
aliceGot := make(chan string, 4)
|
|
if _, err := aliceNC.Subscribe(subjA, func(m *nats.Msg) { aliceGot <- string(m.Data) }); err != nil {
|
|
t.Fatalf("alice sub A: %v", err)
|
|
}
|
|
_ = aliceNC.Flush()
|
|
if e := waitErr(aliceErr, 300*time.Millisecond); e != nil {
|
|
t.Fatalf("alice sub to her OWN room raised an error: %v", e)
|
|
}
|
|
|
|
// alice subscribing to bob's room is a permissions violation.
|
|
if _, err := aliceNC.Subscribe(subjB, func(m *nats.Msg) { aliceGot <- "LEAK:" + string(m.Data) }); err != nil {
|
|
t.Fatalf("alice sub B (queue): %v", err)
|
|
}
|
|
_ = aliceNC.Flush()
|
|
if e := waitErr(aliceErr, 1*time.Second); e == nil {
|
|
t.Fatalf("alice subscribing to bob's room should raise a permissions violation")
|
|
}
|
|
|
|
// bob publishes in his room; alice (denied) must not receive it.
|
|
bobGot := make(chan string, 4)
|
|
if _, err := bobNC.Subscribe(subjB, func(m *nats.Msg) { bobGot <- string(m.Data) }); err != nil {
|
|
t.Fatalf("bob sub B: %v", err)
|
|
}
|
|
_ = bobNC.Flush()
|
|
if err := bobNC.Publish(subjB, []byte("internal-bob")); err != nil {
|
|
t.Fatalf("bob pub B: %v", err)
|
|
}
|
|
_ = bobNC.Flush()
|
|
select {
|
|
case got := <-bobGot:
|
|
if got != "internal-bob" {
|
|
t.Fatalf("bob got %q", got)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("bob did not receive his own message")
|
|
}
|
|
select {
|
|
case leak := <-aliceGot:
|
|
t.Fatalf("alice received bob's room traffic despite the ACL: %q", leak)
|
|
case <-time.After(500 * time.Millisecond):
|
|
// good: alice never got it
|
|
}
|
|
|
|
// alice publishing into bob's room is denied; bob must not receive it.
|
|
drain(aliceErr)
|
|
if err := aliceNC.Publish(subjB, []byte("intruder")); err != nil {
|
|
t.Fatalf("alice pub B (queue): %v", err)
|
|
}
|
|
_ = aliceNC.Flush()
|
|
if e := waitErr(aliceErr, 1*time.Second); e == nil {
|
|
t.Fatalf("alice publishing into bob's room should raise a permissions violation")
|
|
}
|
|
select {
|
|
case got := <-bobGot:
|
|
t.Fatalf("bob received alice's cross-room publish despite the ACL: %q", got)
|
|
case <-time.After(500 * time.Millisecond):
|
|
// good
|
|
}
|
|
}
|
|
|
|
// TestRefreshSessionGainsNewRoom is the "permissions refreshed on join" path:
|
|
// alice is not in room B, so her connection has no permission for its subject;
|
|
// after she is added to room B and calls RefreshSession, the reconnect
|
|
// re-derives her permissions and she gains the room's subject.
|
|
func TestRefreshSessionGainsNewRoom(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
alice, bob := mustID(t), mustID(t)
|
|
aliceEP, bobEP := frame.EndpointID(alice.SignPub), frame.EndpointID(bob.SignPub)
|
|
mustAddUser(t, store, alice, "alice")
|
|
mustAddUser(t, store, bob, "bob")
|
|
const subjB = "room.refresh.b"
|
|
mustCreateRoom(t, store, "ROOMB", subjB, bobEP, bob)
|
|
|
|
srv := startACLNats(t, store)
|
|
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
|
ctrl := newCtrl(t, store, blobs)
|
|
|
|
aliceC, err := client.NewWithOptions(srv.ClientURL(), ctrl, alice, client.Options{UseNkey: true})
|
|
if err != nil {
|
|
t.Fatalf("connect alice: %v", err)
|
|
}
|
|
defer aliceC.Close()
|
|
|
|
// Add alice to room B (as if invited), then RefreshSession so the
|
|
// authenticator re-derives her permissions on reconnect.
|
|
if _, err := store.GetMember("ROOMB", aliceEP); err == nil {
|
|
t.Fatalf("alice should not be a member yet")
|
|
}
|
|
if err := store.AddMember("ROOMB", membership.Member{Endpoint: aliceEP, Role: "member", SignPub: alice.SignPub, KexPub: alice.KexPub}, 1, nil); err != nil {
|
|
t.Fatalf("add alice to room B: %v", err)
|
|
}
|
|
if err := aliceC.RefreshSession(); err != nil {
|
|
t.Fatalf("refresh session: %v", err)
|
|
}
|
|
|
|
bobErr := make(chan error, 2)
|
|
bobNC := nkeyConn(t, srv.ClientURL(), bob, bobErr)
|
|
|
|
got := make(chan string, 2)
|
|
sub, err := aliceC.Subscribe("ROOMB", func(_ frame.Frame, plaintext []byte) { got <- string(plaintext) })
|
|
if err != nil {
|
|
t.Fatalf("alice subscribe room B after refresh: %v", err)
|
|
}
|
|
defer sub.Unsubscribe()
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// bob publishes a minimal cleartext frame on subjB.
|
|
f := frame.Frame{Type: frame.PUB, Subject: subjB, Sender: bobEP, MsgID: "m1", Payload: []byte("hello-after-join")}
|
|
b, _ := f.Marshal()
|
|
if err := bobNC.Publish(subjB, b); err != nil {
|
|
t.Fatalf("bob publish: %v", err)
|
|
}
|
|
_ = bobNC.Flush()
|
|
|
|
select {
|
|
case msg := <-got:
|
|
if msg != "hello-after-join" {
|
|
t.Fatalf("alice got %q", msg)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("alice did not receive room B traffic after RefreshSession (permissions not refreshed)")
|
|
}
|
|
}
|