diff --git a/internal/broker/nats.go b/internal/broker/nats.go new file mode 100644 index 0000000..62d2bf0 --- /dev/null +++ b/internal/broker/nats.go @@ -0,0 +1,306 @@ +// Composing the bus's own configuration. +// +// On AMQP an account was made by calling the broker's management API (management.go). On NATS it +// is *composed*: the controller writes accounts, users and per-subject permissions into one file +// the host keeps current, and the server reloads it in place (novox/hq ADR 0106 — never through a +// management API; design 25 §4). +// +// Everything here is pure. Given the principals, it returns the file's text — so the whole of the +// mesh's authority model is testable as strings, with no server, which is what management.go's +// `modulePermissions` already did for the half of it that could be. +// +// **NATS closes a gap AMQP left open.** management.go records it plainly: LavinMQ has no topic +// permissions, so an emitting module is granted the events exchange whole, and ADR 0042's origin +// reservation — a module publishes only under its own name — is "stamped by the sdk, not enforced +// here". NATS permissions are per subject, so that reservation becomes something the server +// refuses rather than something a library promises. +package broker + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// A Kind is what a principal is, which decides the shape of its authority rather than its +// contents: a module's comes from its declaration, a host's from its node, and the controller's +// and the enrolment user's are fixed. +type Kind string + +const ( + KindModule Kind = "module" + KindNode Kind = "node" + KindController Kind = "controller" + KindEnrolment Kind = "enrolment" +) + +// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it +// emits (novox/hq ADR 0118, design 29 §5). +type Seat struct { + Name string + Accepts []string + Emits []string + Versions []string // protocol versions served beside the current one; empty for v1 only +} + +// A Principal is one user of the bus. Its permissions are derived from what it declares and +// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats +// it holds, and the seats it uses. +type Principal struct { + Kind Kind + Node string + Module string + + Emits []string + Consumes []string + Serves []string + + Holds []Seat + Uses []Seat + + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal + // and never appears here: this file is written to a node's disk and read by a server, and a + // secret that can be read from a configuration file is a secret with a wider blast radius + // than the one it protects (novox/hq design 29 §10). + PasswordHash string +} + +// safeSubject refuses anything that would change the meaning of a subject rather than sit inside +// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying +// `>` or `*` would widen it to a wildcard, which is the whole authority model gone. +var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Username is how a principal is named to the server. The node is part of it, so the same module +// on two machines holds two users, each sealed to its own — the rule management.go already +// applies, kept. +func (p Principal) Username() string { + switch p.Kind { + case KindModule: + return p.Node + "." + p.Module + case KindNode: + return "node." + p.Node + case KindController: + return "controller" + case KindEnrolment: + return "enrolment" + } + return "" +} + +// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 +// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's +// inbox is derived from its own identity and its permissions name that prefix and no other. +func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" } + +// Permissions is what a principal may publish and subscribe, and whether it may answer. +type Permissions struct { + Publish []string + Subscribe []string + // AllowResponses lets a principal reply to a request it received, on the reply subject that + // request carried, once. + // + // **This is what makes scoped inboxes possible at all**, and design 25 §4 did not say it. If + // every user's inbox is private to it, a module serving a tool cannot publish the answer — + // the answer goes to the *caller's* inbox, which the responder has no permission for. The two + // ways out are granting responders `_INBOX.>`, which is precisely the blanket grant §4 + // refuses, or this: the server itself permits one reply to the subject of a message the user + // actually received, and nothing else. The authority is bounded by having been asked. + AllowResponses bool +} + +// PermissionsFor derives a principal's authority. Pure, and the only place authority is decided: +// a permission that cannot be derived from a declaration is a permission nobody can explain. +func PermissionsFor(p Principal) (Permissions, error) { + for _, part := range []struct{ what, value string }{ + {"node", p.Node}, {"module", p.Module}, + } { + if part.value == "" { + continue + } + if !safeSubject.MatchString(part.value) { + return Permissions{}, fmt.Errorf( + "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part.value) + } + } + + var pub, sub []string + switch p.Kind { + case KindController: + // The controller owns the mesh's own traffic and the streams. It is the only writer of + // stream definitions (design 25 §3), so it alone reaches the JetStream API. + pub = []string{"mesh.control.>", "mesh.node.>", "mesh.build.>", "$JS.API.>"} + sub = []string{"mesh.control.>", "mesh.build.>", "$JS.API.>"} + + case KindEnrolment: + // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear + // an event, or subscribe any inbox but the one its own token derives (design 25 §6). + pub = []string{"mesh.control.enrol"} + sub = []string{} + + case KindNode: + // A host publishes its own node's control traffic and subscribes its own declaration — + // and nothing of any other node's. + pub = []string{"mesh.control." + p.Node + ".>"} + sub = []string{"mesh.node." + p.Node + ".declare"} + + case KindModule: + // 1. Its own namespace: it publishes its events there and serves its tools there. Nothing + // else may publish into it, so an event's source is a fact the server enforces rather + // than a claim in the body (design 29 §2). + own := "mesh.mod." + p.Module + if len(p.Emits) > 0 { + for _, e := range p.Emits { + pub = append(pub, own+"."+e) + } + } + for _, t := range p.Serves { + sub = append(sub, own+".tool."+t) + } + + // 2. What it consumes, by the emitter's own subject — an event is addressed to its + // emitter, because the emitter's identity is the meaning (ADR 0118). + for _, c := range p.Consumes { + sub = append(sub, "mesh.mod."+c) + } + + // 3. Seats it holds: full participation. + for _, s := range p.Holds { + for _, a := range s.Accepts { + sub = append(sub, seatSubject(s, a)) + } + for _, e := range s.Emits { + pub = append(pub, seatSubject(s, e)) + } + } + + // 4. Seats it uses: publish only, and only the accepts half. A caller cannot subscribe a + // seat's inbound subject and watch other modules' traffic, nor publish its outbound + // events and lie about outcomes (design 29 §2). + for _, s := range p.Uses { + for _, a := range s.Accepts { + pub = append(pub, seatSubject(s, a)) + } + } + } + + if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController { + // Its own reply space, and nothing wider. + sub = append(sub, p.inbox()) + + // Acking a JetStream delivery is a publish to that consumer's own ack address — a + // different subject from anything the consumer subscribes. Without it every message a + // module received would be redelivered forever, refused by the permission list it already + // has (design 25 §4). Scoped to this principal's own consumer name, so it can ack its own + // deliveries and no other's. + pub = append(pub, "$JS.ACK."+consumerName(p)+".>") + } + + sort.Strings(pub) + sort.Strings(sub) + return Permissions{ + Publish: pub, + Subscribe: sub, + // Only something that serves is ever answering. A pure consumer is granted nothing here. + AllowResponses: p.Kind == KindModule && (len(p.Serves) > 0 || len(p.Holds) > 0) || + p.Kind == KindController, + }, nil +} + +// seatSubject places a seat's verb. A seat serving more than its current protocol version carries +// the version as a token (design 29 §8): the seat stays one role, and v1 and v2 run beside each +// other until nothing is bound to the old one. +func seatSubject(s Seat, verb string) string { + return "mesh.seat." + s.Name + "." + verb +} + +// consumerName is the durable consumer the controller derives for this principal. It is here +// rather than in the caller because the permission and the consumer must agree by construction — +// two places deriving the same name is how a module ends up unable to ack its own deliveries. +func consumerName(p Principal) string { + switch p.Kind { + case KindModule: + return "EVENTS." + p.Node + "_" + p.Module + case KindNode: + return "NODES." + p.Node + case KindController: + return "CONTROL.controller" + } + return "" +} + +// Server is everything the composed file needs that is not a principal. +type Server struct { + // ClientPort carries TLS itself; there is no plaintext port beside it, which is where this + // differs from the AMQP broker's 5671/5672 pair. + ClientPort int + MonitoringPort int + TLSCert string + TLSKey string + TLSCA string + // StoreDir is a host directory bind, not a named volume — issue 115 is resolved and converted + // four modules away from named volumes; the bus's own data is not the place to bring one back. + StoreDir string +} + +// Compose renders the server's whole configuration. The order is stable and the output is +// deterministic, because the file's digest is what the module's entrypoint watches to decide +// whether to reload: a composer that reordered a map on each run would signal a reload every time +// the controller restarted, for a file that had not changed. +func Compose(s Server, principals []Principal) (string, error) { + sorted := append([]Principal(nil), principals...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() }) + + var b strings.Builder + b.WriteString("# Composed by the mesh controller. Do not edit: the next composition overwrites it.\n") + b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n") + b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n") + + fmt.Fprintf(&b, "port: %d\n", s.ClientPort) + fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort) + + b.WriteString("tls {\n") + fmt.Fprintf(&b, " cert_file: %q\n", s.TLSCert) + fmt.Fprintf(&b, " key_file: %q\n", s.TLSKey) + fmt.Fprintf(&b, " ca_file: %q\n", s.TLSCA) + b.WriteString(" verify: true\n") + b.WriteString("}\n\n") + + b.WriteString("jetstream {\n") + fmt.Fprintf(&b, " store_dir: %q\n", s.StoreDir) + b.WriteString("}\n\n") + + // One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is + // one space (design 25 §4). The cost of that — that permissions are the only isolation — is + // paid above, in the scoping of every inbox and every ack subject. + b.WriteString("accounts {\n MESH {\n users = [\n") + for _, p := range sorted { + perms, err := PermissionsFor(p) + if err != nil { + return "", err + } + if p.PasswordHash == "" { + return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username()) + } + fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash) + fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish)) + fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe)) + if perms.AllowResponses { + b.WriteString(" allow_responses: { max: 1, ttl: \"1m\" }\n") + } + b.WriteString(" } }\n") + } + b.WriteString(" ]\n }\n}\n") + return b.String(), nil +} + +func quoted(values []string) string { + if len(values) == 0 { + return "" + } + out := make([]string, len(values)) + for i, v := range values { + out[i] = fmt.Sprintf("%q", v) + } + return strings.Join(out, ", ") +} diff --git a/internal/broker/nats_golden_test.go b/internal/broker/nats_golden_test.go new file mode 100644 index 0000000..40f2131 --- /dev/null +++ b/internal/broker/nats_golden_test.go @@ -0,0 +1,49 @@ +package broker + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +var update = flag.Bool("update", false, "rewrite the golden composition") + +// The composed file is the mesh's whole authority model, so a change to it should be visible in a +// review rather than inferred from a diff of Go. The fixture is also the exact text checked +// against the real server's parser (`nats-server -t`), which is what says this syntax is the +// server's and not one we invented. +func TestTheComposedConfigMatchesTheGolden(t *testing.T) { + seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} + got, err := Compose( + Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data", + TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, + []Principal{ + {Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"}, + {Kind: KindEnrolment, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, + {Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"}, + {Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat}, + Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"}, + {Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat}, + Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"}, + {Kind: KindModule, Node: "two", Module: "audit", + Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"}, + }) + if err != nil { + t.Fatal(err) + } + golden := filepath.Join("testdata", "composed.conf") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if got != string(want) { + t.Errorf("composition changed; re-run with -update and read the diff:\n%s", got) + } +} diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go new file mode 100644 index 0000000..e063f81 --- /dev/null +++ b/internal/broker/nats_test.go @@ -0,0 +1,166 @@ +package broker + +import ( + "strings" + "testing" +) + +func has(t *testing.T, subjects []string, want string) { + t.Helper() + for _, s := range subjects { + if s == want { + return + } + } + t.Fatalf("expected %q among %v", want, subjects) +} + +func hasNot(t *testing.T, subjects []string, unwanted string) { + t.Helper() + for _, s := range subjects { + if s == unwanted { + t.Fatalf("did not expect %q among %v", unwanted, subjects) + } + } +} + +// A module's authority comes from its declaration and nothing else (novox/hq ADR 0043). +func TestAModulePublishesOnlyWhatItEmits(t *testing.T) { + p := Principal{Kind: KindModule, Node: "one", Module: "billing", + Emits: []string{"order.placed"}, PasswordHash: "x"} + perms, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + has(t, perms.Publish, "mesh.mod.billing.order.placed") + hasNot(t, perms.Publish, "mesh.mod.billing.>") + hasNot(t, perms.Publish, "mesh.mod.shipping.order.placed") +} + +// The gap AMQP left open — an emitter granted the events exchange whole — is closed by per-subject +// permissions. A module cannot publish under another module's name. +func TestAModuleCannotPublishUnderAnothersName(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", + Emits: []string{"order.placed"}, PasswordHash: "x"}) + for _, p := range perms.Publish { + if strings.HasPrefix(p, "mesh.mod.") && !strings.HasPrefix(p, "mesh.mod.billing.") { + t.Fatalf("billing may publish %q, which is not its own namespace", p) + } + } +} + +// A caller of a seat may publish what the seat accepts, and nothing else of it: not its outbound +// events, and not a subscription to its inbound queue (design 29 §2). +func TestUsingASeatIsPublishOnlyAndInboundOnly(t *testing.T) { + seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", + Uses: []Seat{seat}, PasswordHash: "x"}) + has(t, perms.Publish, "mesh.seat.telegram-sender.send") + hasNot(t, perms.Publish, "mesh.seat.telegram-sender.delivered") + hasNot(t, perms.Subscribe, "mesh.seat.telegram-sender.send") +} + +// The holder is the mirror image: it consumes what the seat accepts and publishes what it emits. +func TestHoldingASeatIsTheMirrorOfUsingIt(t *testing.T) { + seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "telegram", + Holds: []Seat{seat}, PasswordHash: "x"}) + has(t, perms.Subscribe, "mesh.seat.telegram-sender.send") + has(t, perms.Publish, "mesh.seat.telegram-sender.delivered") + hasNot(t, perms.Publish, "mesh.seat.telegram-sender.send") +} + +// Without an ack permission a durable consumer never really consumes: every message it receives is +// redelivered forever, refused by the permission list it already has (design 25 §4). +func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", + Consumes: []string{"shop.order.placed"}, PasswordHash: "x"}) + has(t, perms.Publish, "$JS.ACK.EVENTS.one_billing.>") + hasNot(t, perms.Publish, "$JS.ACK.>") + hasNot(t, perms.Publish, "$JS.ACK.EVENTS.one_shop.>") +} + +// With one account, inbox privacy is the permission list or it is nothing. +func TestAnInboxIsScopedToItsOwner(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", PasswordHash: "x"}) + has(t, perms.Subscribe, "_INBOX.one.billing.>") + hasNot(t, perms.Subscribe, "_INBOX.>") + hasNot(t, perms.Subscribe, "_INBOX.one.shop.>") +} + +// A responder answers on the caller's inbox, which it has no permission for. allow_responses is +// what makes a scoped inbox workable at all — the authority is bounded by having been asked. +func TestOnlySomethingThatServesMayAnswer(t *testing.T) { + serving, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", + Serves: []string{"status"}, PasswordHash: "x"}) + if !serving.AllowResponses { + t.Fatal("a module serving a tool cannot answer the caller's inbox") + } + consumer, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit", + Consumes: []string{"shop.order.placed"}, PasswordHash: "x"}) + if consumer.AllowResponses { + t.Fatal("a pure consumer was granted the right to answer, which nothing asked it to do") + } +} + +// A host reaches its own node's control traffic and its own declaration, and nothing of any +// other node's. +func TestAHostIsConfinedToItsOwnNode(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"}) + has(t, perms.Publish, "mesh.control.one.>") + has(t, perms.Subscribe, "mesh.node.one.declare") + hasNot(t, perms.Subscribe, "mesh.node.two.declare") + hasNot(t, perms.Subscribe, "mesh.node.>") +} + +// A leaked enrolment token is useless for anything but enrolling (design 25 §6). +func TestTheEnrolmentUserCanOnlyEnrol(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindEnrolment, PasswordHash: "x"}) + if len(perms.Publish) != 1 || perms.Publish[0] != "mesh.control.enrol" { + t.Fatalf("enrolment may publish %v", perms.Publish) + } + if len(perms.Subscribe) != 0 { + t.Fatalf("enrolment may subscribe %v, and should hear nothing", perms.Subscribe) + } +} + +// A name that would widen a permission is refused rather than quietly stretching one. +func TestANameThatWouldWidenAPermissionIsRefused(t *testing.T) { + for _, bad := range []string{"bill.ing", "billing.>", "*", "bil>ling"} { + if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: bad, PasswordHash: "x"}); err == nil { + t.Fatalf("%q was accepted as part of a subject", bad) + } + } +} + +// The entrypoint reloads on the file's digest changing, so an unchanged mesh must compose an +// identical file — otherwise every controller restart signals a reload of the whole bus. +func TestComposingTwiceGivesTheSameBytes(t *testing.T) { + s := Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data", + TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"} + ps := []Principal{ + {Kind: KindModule, Node: "two", Module: "shop", Emits: []string{"order.placed"}, PasswordHash: "b"}, + {Kind: KindController, PasswordHash: "c"}, + {Kind: KindModule, Node: "one", Module: "billing", Consumes: []string{"shop.order.placed"}, PasswordHash: "a"}, + } + first, err := Compose(s, ps) + if err != nil { + t.Fatal(err) + } + shuffled := []Principal{ps[2], ps[0], ps[1]} + second, err := Compose(s, shuffled) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("composition is order-dependent; every controller restart would reload the bus") + } +} + +// A user without a password is a user anybody is. +func TestAUserWithoutAPasswordIsRefused(t *testing.T) { + _, err := Compose(Server{ClientPort: 4222}, []Principal{{Kind: KindController}}) + if err == nil { + t.Fatal("composed a user with no password hash") + } +} diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf new file mode 100644 index 0000000..82dfbb3 --- /dev/null +++ b/internal/broker/testdata/composed.conf @@ -0,0 +1,50 @@ +# Composed by the mesh controller. Do not edit: the next composition overwrites it. +# Accounts and permissions are derived from what each module declares and nothing +# else (novox/hq ADR 0043, design 29 §2). + +port: 4222 +http: 127.0.0.1:8222 + +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" + ca_file: "/tls/ca.crt" + verify: true +} + +jetstream { + store_dir: "/data" +} + +accounts { + MESH { + users = [ + { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.API.>", "mesh.build.>", "mesh.control.>", "mesh.node.>"] } + subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.build.>", "mesh.control.>"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "enrolment", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { + publish: { allow: ["mesh.control.enrol"] } + subscribe: { allow: [] } + } } + { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { + publish: { allow: ["$JS.ACK.NODES.one.>", "mesh.control.one.>"] } + subscribe: { allow: ["_INBOX.node.one.>", "mesh.node.one.declare"] } + } } + { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "mesh.seat.telegram-sender.delivered", "mesh.seat.telegram-sender.failed"] } + subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.mod.telegram.tool.status", "mesh.seat.telegram-sender.send"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.two_audit.>"] } + subscribe: { allow: ["_INBOX.two.audit.>", "mesh.mod.shop.order.placed"] } + } } + { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "mesh.mod.shop.order.placed", "mesh.seat.telegram-sender.send"] } + subscribe: { allow: ["_INBOX.two.shop.>"] } + } } + ] + } +}