From eb76a935bbc4a0d6ddc7558db0ace4433804bd3d Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 25 Apr 2026 02:10:48 +0930 Subject: [PATCH 1/4] json api --- .claude/settings.json | 10 ++++ CHANGELOG.md | 4 +- config/config.exs | 1 + diffo_example.livemd | 74 +++++++++++++++++++----------- lib/diffo_example/application.ex | 7 ++- lib/nbn/api_router.ex | 10 ++++ lib/nbn/catalog.ex | 25 ++++++++++ lib/nbn/initializer.ex | 26 +++++++++++ lib/nbn/nbn.ex | 76 ++++++++++++++++++++++++++++++- lib/nbn/resources/avc.ex | 11 +++-- lib/nbn/resources/cvc.ex | 7 ++- lib/nbn/resources/nbn_ethernet.ex | 10 +++- lib/nbn/resources/nni.ex | 7 ++- lib/nbn/resources/nni_group.ex | 7 ++- lib/nbn/resources/ntd.ex | 7 ++- lib/nbn/resources/uni.ex | 11 +++-- lib/nbn/router.ex | 35 ++++++++++++++ mix.exs | 3 ++ mix.lock | 16 +++++++ 19 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 .claude/settings.json create mode 100644 lib/nbn/api_router.ex create mode 100644 lib/nbn/catalog.ex create mode 100644 lib/nbn/initializer.ex create mode 100644 lib/nbn/router.ex diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e421007 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(mix deps.get)", + "Bash(git -C /Users/beanlanda/git/diffo_example status)", + "Read(//Users/beanlanda/git/**)", + "Read(//Users/beanlanda/.mix/**)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2c60b..b033778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,4 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * updated to diffo 0.2.0 ### Features: -* new NBN domain modelling NBN Ethernet access and constituent resources (UNI, AVC, NTD, CVC, NNI Group, NNI) -* NBN Technology and Speeds as Ash Enum types -* speeds derived from NTD technology and AVC bandwidth_profile via mine action \ No newline at end of file +* new NBN domain modelling NBN Ethernet access and constituent resources (UNI, AVC, NTD, CVC, NNI Group, NNI), JSON API and livebook \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index ff4d1b4..9920208 100644 --- a/config/config.exs +++ b/config/config.exs @@ -16,6 +16,7 @@ config :spark, :characteristics, :neo4j, :jason, + :json_api, :outstanding, :actions, :state_machine, diff --git a/diffo_example.livemd b/diffo_example.livemd index 3dc40c2..830be14 100644 --- a/diffo_example.livemd +++ b/diffo_example.livemd @@ -9,7 +9,21 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo_example, "~> 0.2.0"} + {:diffo_example, "~> 0.2.0"}, + {:req, "~> 0.5"} + ], + config: [ + bolty: [{Bolt, [ + uri: "bolt://localhost:7687", + auth: [username: "neo4j", password: "password"], + user_agent: "diffoExampleLivebook/1", + pool_size: 15, + max_overflow: 3, + prefix: :default, + name: Bolt, + log: false, + log_hex: false + ]}] ], consolidate_protocols: false ) @@ -37,25 +51,9 @@ The NBN domain models a fictional NBN Ethernet access circuit and its constituen ## Installing Neo4j and Configuring Bolty -Update the configuration below to match your Neo4j installation and evaluate. +Bolty is configured in the `Mix.install` block above — update the Neo4j credentials there if needed before evaluating. -```elixir -config = [ - uri: "bolt://localhost:7687", - auth: [username: "neo4j", password: "password"], - user_agent: "diffoExampleLivebook/1", - pool_size: 15, - max_overflow: 3, - prefix: :default, - name: Bolt, - log: false, - log_hex: false -] -``` - -```elixir -AshNeo4j.BoltyHelper.start(config) -``` +You need [Neo4j](https://neo4j.com/deployment-center/) installed and running. Verify the connection: ```elixir AshNeo4j.BoltyHelper.is_connected() @@ -148,11 +146,11 @@ nni_group |> Jason.encode!(pretty: true) |> IO.puts Define the NNI Group with an SVLAN assignment and relate the NNI: ```elixir -nni_group = Nbn.define_nni_group!(%{ +nni_group = Nbn.define_nni_group!(nni_group, %{ characteristic_value_updates: [nni_group: [svlan: 100]] }) nni_group = Nbn.relate_nni_group!(nni_group, %{ - relationships: [%{alias: :nni, target_id: nni.id, type: :isAssigned}] + relationships: [%Diffo.Provider.Instance.Relationship{id: nni.id, alias: :nni, type: :isAssigned}] }) nni_group |> Jason.encode!(pretty: true) |> IO.puts ``` @@ -162,7 +160,7 @@ Build a CVC — the aggregation virtual circuit that terminates at the NNI Group ```elixir cvc = Nbn.build_cvc!(%{}) cvc = Nbn.relate_cvc!(cvc, %{ - relationships: [%{alias: :nni_group, target_id: nni_group.id, type: :isAssigned}] + relationships: [%Diffo.Provider.Instance.Relationship{id: nni_group.id, alias: :nni_group, type: :isAssigned}] }) cvc |> Jason.encode!(pretty: true) |> IO.puts ``` @@ -187,7 +185,7 @@ Build a UNI — the interface at the customer premises — and assign a port fro uni = Nbn.build_uni!(%{}) alias Diffo.Provider.Assignment ntd = Nbn.assign_port!(ntd, %{ - assignment: %Assignment{assignee_id: uni.id, value: 1} + assignment: %Assignment{assignee_id: uni.id, operation: :auto_assign} }) ntd |> Jason.encode!(pretty: true) |> IO.puts ``` @@ -196,7 +194,7 @@ Relate the UNI back to the NTD so it can mine technology and port from it: ```elixir uni = Nbn.relate_uni!(uni, %{ - relationships: [%{alias: :ntd, target_id: ntd.id, type: :isAssigned}] + relationships: [%Diffo.Provider.Instance.Relationship{id: ntd.id, alias: :ntd, type: :isAssigned}] }) uni = Nbn.mine_uni!(uni, %{}) uni |> Jason.encode!(pretty: true) |> IO.puts @@ -210,7 +208,7 @@ avc = Nbn.define_avc!(avc, %{ characteristic_value_updates: [avc: [bandwidth_profile: :home_ultrafast]] }) cvc = Nbn.assign_cvlan!(cvc, %{ - assignment: %Assignment{assignee_id: avc.id, value: 200} + assignment: %Assignment{assignee_id: avc.id, operation: :auto_assign} }) avc = Nbn.mine_avc!(avc, %{}) avc |> Jason.encode!(pretty: true) |> IO.puts @@ -222,8 +220,8 @@ Now build the top-level NBN Ethernet access and relate it to both the UNI and AV pri = Nbn.build_nbn_ethernet!(%{}) pri = Nbn.relate_nbn_ethernet!(pri, %{ relationships: [ - %{alias: :uni, target_id: uni.id, type: :isAssigned}, - %{alias: :avc, target_id: avc.id, type: :isAssigned} + %Diffo.Provider.Instance.Relationship{id: uni.id, alias: :uni, type: :isAssigned}, + %Diffo.Provider.Instance.Relationship{id: avc.id, alias: :avc, type: :isAssigned} ] }) pri = Nbn.mine_nbn_ethernet!(pri, %{}) @@ -246,6 +244,28 @@ Or from Elixir: AshNeo4j.Cypher.run("MATCH (n1)-[r]->(n2) RETURN r, n1, n2 LIMIT 50") ``` +## JSON API + +The NBN domain exposes a JSON API via `Plug.Cowboy` on port 4000. Start the server in your application before evaluating these cells. + +First check the catalog — all NBN specifications are initialised on startup: + +```elixir +Req.get!("http://localhost:4000/catalog").body |> Jason.encode!(pretty: true) |> IO.puts() +``` + +Now retrieve all NBN Ethernet instances: + +```elixir +Req.get!("http://localhost:4000/nbnEthernet").body |> Jason.encode!(pretty: true) |> IO.puts() +``` + +Or fetch the one we provisioned above by id: + +```elixir +Req.get!("http://localhost:4000/nbnEthernet/#{pri.id}").body |> Jason.encode!(pretty: true) |> IO.puts() +``` + ## What Next? You've provisioned a complete NBN Ethernet access — NTD, UNI, AVC, CVC, NNI Group, and NNI — and seen how the `mine` actions propagate technology, speeds, CVLAN and port assignments up the resource hierarchy automatically. diff --git a/lib/diffo_example/application.ex b/lib/diffo_example/application.ex index a9b8835..8bca8f1 100644 --- a/lib/diffo_example/application.ex +++ b/lib/diffo_example/application.ex @@ -9,6 +9,11 @@ defmodule DiffoExample.Application do @impl true def start(_type, _args) do - Supervisor.start_link([], strategy: :one_for_one) + children = [ + {Plug.Cowboy, scheme: :http, plug: DiffoExample.Nbn.Router, options: [port: 4000]}, + {Task, &DiffoExample.Nbn.Initializer.init/0} + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: DiffoExample.Supervisor) end end diff --git a/lib/nbn/api_router.ex b/lib/nbn/api_router.ex new file mode 100644 index 0000000..ffa0828 --- /dev/null +++ b/lib/nbn/api_router.ex @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.ApiRouter do + @moduledoc false + use AshJsonApi.Router, + domains: [DiffoExample.Nbn], + open_api: "/open_api" +end diff --git a/lib/nbn/catalog.ex b/lib/nbn/catalog.ex new file mode 100644 index 0000000..3a3ab3f --- /dev/null +++ b/lib/nbn/catalog.ex @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Catalog do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Catalog - the NBN resource and service catalog. + """ + + def list do + Diffo.Provider.list_specifications!() + |> Enum.map(fn spec -> + Jason.OrderedObject.new( + id: spec.id, + href: spec.href, + name: spec.name, + version: spec.version, + description: spec.description, + category: spec.category + ) + end) + end +end diff --git a/lib/nbn/initializer.ex b/lib/nbn/initializer.ex new file mode 100644 index 0000000..d261b87 --- /dev/null +++ b/lib/nbn/initializer.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Initializer do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Initializes the NBN domain's specifications in the catalog on application startup, + so the catalog is populated before any instances are built. + """ + + alias Diffo.Provider.Instance.Specification + + def init do + DiffoExample.Nbn + |> Ash.Domain.Info.resources() + |> Enum.each(fn module -> + try do + Specification.upsert_specification(module) + rescue + _ -> :ok + end + end) + end +end diff --git a/lib/nbn/nbn.ex b/lib/nbn/nbn.ex index 3ae584e..b739947 100644 --- a/lib/nbn/nbn.ex +++ b/lib/nbn/nbn.ex @@ -13,7 +13,8 @@ defmodule DiffoExample.Nbn do CVC (aggregates AVCs, terminates at NNI Group), NNI Group, and NNI. """ use Ash.Domain, - otp_app: :diffo + otp_app: :diffo, + extensions: [AshJsonApi.Domain] alias DiffoExample.Nbn.NbnEthernet alias DiffoExample.Nbn.Uni @@ -27,6 +28,78 @@ defmodule DiffoExample.Nbn do description "An example showing how TMF Resources for a fictional NBN domain can be extended from the Provider domain" end + json_api do + routes do + base_route "/nbnEthernet", NbnEthernet do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + patch :mine, route: "/:id/mine" + delete :destroy + end + + base_route "/uni", Uni do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + patch :mine, route: "/:id/mine" + delete :destroy + end + + base_route "/avc", Avc do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + patch :mine, route: "/:id/mine" + delete :destroy + end + + base_route "/ntd", Ntd do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + delete :destroy + end + + base_route "/cvc", Cvc do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + patch :mine, route: "/:id/mine" + delete :destroy + end + + base_route "/nniGroup", NniGroup do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + delete :destroy + end + + base_route "/nni", Nni do + index :read + get :read + post :build + patch :define + patch :relate, route: "/:id/relate" + delete :destroy + end + + end + end + resources do resource NbnEthernet do define :get_nbn_ethernet_by_id, action: :read, get_by: :id @@ -83,5 +156,6 @@ defmodule DiffoExample.Nbn do define :define_nni, action: :define define :relate_nni, action: :relate end + end end diff --git a/lib/nbn/resources/avc.ex b/lib/nbn/resources/avc.ex index 467d699..9924932 100644 --- a/lib/nbn/resources/avc.ex +++ b/lib/nbn/resources/avc.ex @@ -21,7 +21,12 @@ defmodule DiffoExample.Nbn.Avc do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "avc" + end resource do description "An Ash Resource representing an Access Virtual Circuit (AVC)" @@ -110,9 +115,9 @@ defmodule DiffoExample.Nbn.Avc do # mines related resource to characteristics def mine_related(changeset, _context) when is_struct(changeset, Ash.Changeset) do - reverse_relationships = Ash.Changeset.get_attribute(changeset, :reverse_relationships) + avc = Ash.load!(changeset.data, [reverse_relationships: [:characteristics]]) - cvlan = {:cvlan, Diffo.Unwrap.unwrap(hd(hd(reverse_relationships).characteristics).value)} + cvlan = {:cvlan, Diffo.Unwrap.unwrap(hd(hd(avc.reverse_relationships).characteristics).value)} Ash.Changeset.force_set_argument(changeset, :characteristic_value_updates, avc: [cvlan]) end diff --git a/lib/nbn/resources/cvc.ex b/lib/nbn/resources/cvc.ex index 8e75dcf..81d67a2 100644 --- a/lib/nbn/resources/cvc.ex +++ b/lib/nbn/resources/cvc.ex @@ -23,7 +23,12 @@ defmodule DiffoExample.Nbn.Cvc do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "cvc" + end resource do description "An Ash Resource representing a Connectivity Virtual Circuit (CVC)" diff --git a/lib/nbn/resources/nbn_ethernet.ex b/lib/nbn/resources/nbn_ethernet.ex index d4594a5..26e37ca 100644 --- a/lib/nbn/resources/nbn_ethernet.ex +++ b/lib/nbn/resources/nbn_ethernet.ex @@ -22,7 +22,12 @@ defmodule DiffoExample.Nbn.NbnEthernet do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "nbnEthernet" + end resource do description "An Ash Resource representing an NBN Ethernet access" @@ -114,7 +119,8 @@ defmodule DiffoExample.Nbn.NbnEthernet do # mines related resource to characteristics def mine_related(changeset, _context) when is_struct(changeset, Ash.Changeset) do - forward_relationships = Ash.Changeset.get_attribute(changeset, :forward_relationships) + pri = Ash.load!(changeset.data, [:forward_relationships]) + forward_relationships = pri.forward_relationships pri_updates = Enum.reduce(forward_relationships, [], fn forward_relationship, acc -> diff --git a/lib/nbn/resources/nni.ex b/lib/nbn/resources/nni.ex index 92785a5..e9b84f6 100644 --- a/lib/nbn/resources/nni.ex +++ b/lib/nbn/resources/nni.ex @@ -22,7 +22,12 @@ defmodule DiffoExample.Nbn.Nni do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "nni" + end resource do description "An Ash Resource representing a Network-to-Network Interface (NNI)" diff --git a/lib/nbn/resources/nni_group.ex b/lib/nbn/resources/nni_group.ex index 9629d5b..9e89a33 100644 --- a/lib/nbn/resources/nni_group.ex +++ b/lib/nbn/resources/nni_group.ex @@ -24,7 +24,12 @@ defmodule DiffoExample.Nbn.NniGroup do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "nniGroup" + end resource do description "An Ash Resource representing an NNI Group" diff --git a/lib/nbn/resources/ntd.ex b/lib/nbn/resources/ntd.ex index b9fa3ea..c7a66e5 100644 --- a/lib/nbn/resources/ntd.ex +++ b/lib/nbn/resources/ntd.ex @@ -23,7 +23,12 @@ defmodule DiffoExample.Nbn.Ntd do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "ntd" + end resource do description "An Ash Resource representing a Network Termination Device (NTD)" diff --git a/lib/nbn/resources/uni.ex b/lib/nbn/resources/uni.ex index 0abd419..6910cb4 100644 --- a/lib/nbn/resources/uni.ex +++ b/lib/nbn/resources/uni.ex @@ -23,7 +23,12 @@ defmodule DiffoExample.Nbn.Uni do use Ash.Resource, fragments: [BaseInstance], - domain: Nbn + domain: Nbn, + extensions: [AshJsonApi.Resource] + + json_api do + type "uni" + end resource do description "An Ash Resource representing a User Network Interface (UNI)" @@ -111,9 +116,9 @@ defmodule DiffoExample.Nbn.Uni do # mines related resource to characteristics def mine_related(changeset, _context) when is_struct(changeset, Ash.Changeset) do - reverse_relationships = Ash.Changeset.get_attribute(changeset, :reverse_relationships) + uni = Ash.load!(changeset.data, [reverse_relationships: [:characteristics]]) - ntd_relationship = hd(reverse_relationships) + ntd_relationship = hd(uni.reverse_relationships) port = {:port, Diffo.Unwrap.unwrap(hd(ntd_relationship.characteristics).value)} {:ok, ntd} = Diffo.Provider.get_instance_by_id(ntd_relationship.source_id) diff --git a/lib/nbn/router.ex b/lib/nbn/router.ex new file mode 100644 index 0000000..c20452c --- /dev/null +++ b/lib/nbn/router.ex @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Router do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + NBN HTTP router. Handles the catalog endpoint directly and forwards + all JSON API traffic to the AshJsonApi router. + + Start with: + + Plug.Cowboy.http(DiffoExample.Nbn.Router, [], port: 4000) + """ + use Plug.Router + + plug Plug.Parsers, + parsers: [:json], + pass: ["application/vnd.api+json", "application/json"], + json_decoder: Jason + + plug :match + plug :dispatch + + get "/catalog" do + result = Jason.encode!(DiffoExample.Nbn.Catalog.list()) + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, result) + end + + forward "/", to: DiffoExample.Nbn.ApiRouter +end diff --git a/mix.exs b/mix.exs index 7f33924..614a1e5 100644 --- a/mix.exs +++ b/mix.exs @@ -81,6 +81,9 @@ defmodule DiffoExample.MixProject do defp deps do [ {:diffo, diffo_version("~> 0.2.0")}, + {:ash_json_api, "~> 1.6"}, + {:plug_cowboy, "~> 2.7"}, + {:req, "~> 0.5", only: [:dev, :test]}, {:igniter, "~> 0.6", only: [:dev, :test]}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index 04acf2a..233d094 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,15 @@ %{ "ash": {:hex, :ash, "3.24.3", "f7280a43c5e64f769a450f3dd59ace6dcd73edcdd0de7599815b1b31f59292fb", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c1022f8c549632137cbc8956f07bb4981405297f5abe7a752b4dffac175c3381"}, "ash_jason": {:hex, :ash_jason, "3.1.0", "84a88dfe5e25a20d55cf2d2664885cd086fa45871e8777aedc3ad96a282e2a6f", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.1.21 and < 3.0.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "71e6bbc421fb2cf7079f8804814145cca458116c839fc798f9606b806e07eb2b"}, + "ash_json_api": {:hex, :ash_json_api, "1.6.5", "ff925107ebdced10407a6045dc3ff9e8335fe3485ce042f899817a2b47f49b5f", [:mix], [{:ash, ">= 3.19.1 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.58 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "ab2f413d977a560843bbf7a7f6bc486b74e944ef51d9adf93c355a4bf984b0df"}, "ash_neo4j": {:hex, :ash_neo4j, "0.3.1", "52b81e870d020815ffb2699f3fa207e10e909418e80c8aec4c64ed668418299a", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.10", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "5da556d93e03fda97e1bb626941114b7011a64173b1c10deb12cf66523e82001"}, "ash_outstanding": {:hex, :ash_outstanding, "0.2.4", "c72b91f1b8e4859fb033eddf66d0ba36cfd8af0c2a9748c7ef9e6ccfdb5d093d", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:outstanding, "~> 0.2.4", [hex: :outstanding, repo: "hexpm", optional: false]}], "hexpm", "64ba8f582ce69c9050352c75f0895db186c7a56f35039dab34c8e1ab7516f9ce"}, "ash_state_machine": {:hex, :ash_state_machine, "0.2.13", "e1c368ebf01ef73477739ee76d53e513d073b141ec11e7bf7f91d8f2d8fc9569", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "aa21c92a8950850df69b5205bf41efc1e502f5ab839425ba08561f0421c9f226"}, "bolty": {:hex, :bolty, "0.0.10", "ec88948d30cfc213cdb1168f86d96cdcadd80f16e4f29701966e69dfbac43ded", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2ce63d6c23301d1c9a61fd29ef06ebb7d2e775d4fd4144e86c2717aa43f409c9"}, + "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -19,6 +24,7 @@ "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "json_xema": {:hex, :json_xema, "0.6.5", "060459c9c9152650edb4427b1acbc61fa43a23bcea0301d200cafa76e0880f37", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.16", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "b8ffdbc2f67aa8b91b44e1ba0ab77eb5c0b0142116f8fbb804977fb939d470ef"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, @@ -30,6 +36,13 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "outstanding": {:hex, :outstanding, "0.2.5", "2f40416eb9617748cb1f8ae4c8ed94515d731f9c4fcee4f902355d30bc0792cc", [:mix], [], "hexpm", "bb47a210f0d2804ea6b8477fa6f4d15e8c58c18acee79d8e06c9296e6dd004cd"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, @@ -41,6 +54,9 @@ "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "xema": {:hex, :xema, "0.17.7", "7eeda174b70a5f7fb1cc2e9fa3a7d4e78e206a99866c107d477309410b678cf2", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "7e3d7c0629282c21af6aaa5e2ba593218cd764a57bd1ae49e2c4412324e904cd"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, From 61f8a3742c55ebf0859fd78c8a68f7c9c9f495c9 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 26 Apr 2026 06:12:38 +0930 Subject: [PATCH 2/4] =?UTF-8?q?stop=20tracking=20.claude/settings.json=20?= =?UTF-8?q?=E2=80=94=20already=20in=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e421007..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(mix deps.get)", - "Bash(git -C /Users/beanlanda/git/diffo_example status)", - "Read(//Users/beanlanda/git/**)", - "Read(//Users/beanlanda/.mix/**)" - ] - } -} From a19c3f54d552f102ca76f28b3455b1a90c6a9651 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 26 Apr 2026 09:47:29 +0930 Subject: [PATCH 3/4] add RSP multi-tenancy, JSON API, and NBN domain documentation for v0.2.0 - RSP resource with AshStateMachine lifecycle (inactive/active/suspended) - Ash Policy multi-tenancy: SetRspId change, OwnedByActor and NoActor checks, RspOwnership macro shared across 5 RSP-owned resources - NTD and UNI are NBN-owned infrastructure: readable by any RSP, mutable only by nil actor - JSON API via AshJsonApi and Plug.Cowboy on port 4000 - RSP list action with epid sort; field_policy restricts state visibility to record owner - Livebook moved to documentation/domains/diffo_example_nbn.livemd with Kino RSP selector and actor-scoped provisioning flow - documentation/domains/nbn.md: Perentie ecosystem narrative and RSP spirit animals - README updated to describe both NBN and Access domains --- .gitignore | 5 +- README.md | 23 +-- .../domains/diffo_example_nbn.livemd | 131 ++++++++------- documentation/domains/nbn.md | 23 +++ lib/diffo_example/application.ex | 11 +- lib/nbn/changes/set_rsp_id.ex | 13 ++ lib/nbn/checks/no_actor.ex | 15 ++ lib/nbn/checks/owned_by_actor.ex | 19 +++ lib/nbn/initializer.ex | 39 ++++- lib/nbn/nbn.ex | 15 ++ lib/nbn/resources/avc.ex | 15 +- lib/nbn/resources/cvc.ex | 15 +- lib/nbn/resources/nbn_ethernet.ex | 15 +- lib/nbn/resources/nni.ex | 15 +- lib/nbn/resources/nni_group.ex | 15 +- lib/nbn/resources/ntd.ex | 17 +- lib/nbn/resources/rsp.ex | 152 ++++++++++++++++++ lib/nbn/resources/uni.ex | 17 +- lib/nbn/rsp_ownership.ex | 37 +++++ mix.exs | 10 +- mix.lock | 3 + test/nbn/rsp_test.exs | 132 +++++++++++++++ 22 files changed, 657 insertions(+), 80 deletions(-) rename diffo_example.livemd => documentation/domains/diffo_example_nbn.livemd (69%) create mode 100644 documentation/domains/nbn.md create mode 100644 lib/nbn/changes/set_rsp_id.ex create mode 100644 lib/nbn/checks/no_actor.ex create mode 100644 lib/nbn/checks/owned_by_actor.ex create mode 100644 lib/nbn/resources/rsp.ex create mode 100644 lib/nbn/rsp_ownership.ex create mode 100644 test/nbn/rsp_test.exs diff --git a/.gitignore b/.gitignore index 03f6fe6..9481f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ diffo-*.tar /.elixir_ls -.DS_Store \ No newline at end of file +.DS_Store + +# Agent related +.claude/* \ No newline at end of file diff --git a/README.md b/README.md index adddd0e..0ffb9dc 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,24 @@ SPDX-License-Identifier: MIT [![Module Version](https://img.shields.io/hexpm/v/diffo)](https://hex.pm/packages/diffo_example) [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen)](https://hexdocs.pm/diffo_example/) -[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo-dev%2Fdiffo_example%2Fblob%2Fmain%2Fdiffo_example.livemd) [![License](https://img.shields.io/hexpm/l/diffo)](https://github.com/diffo-dev/diffo_example/blob/master/LICENSES/MIT.md) [![REUSE status](https://api.reuse.software/badge/github.com/diffo-dev/diffo_example)](https://api.reuse.software/info/github.com/diffo-dev/diffo_example) -This repo contains Diffo Examples. - [Diffo](https://github.com/diffo-dev/diffo) is a Telecommunications Management Forum (TMF) Service and Resource Manager, built for autonomous networks. +This repo contains two independent example domains, each modelling a different slice of a telco network. + +## NBN Domain + +A declarative model of a fictional NBN Ethernet access hierarchy — NbnEthernet, UNI, AVC, NTD, CVC, NNI Group, and NNI — built entirely with the Diffo Provider Instance DSL. Includes multi-tenancy via Ash Policy: each RSP can only see and manage the resources they own. + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo-dev%2Fdiffo_example%2Fblob%2Fdev%2Fdocumentation%2Fdomains%2Fdiffo_example_nbn.livemd) + +The livebook walks through provisioning a complete NBN Ethernet access circuit, selecting an RSP to operate as, and demonstrating how the `mine` actions propagate technology, speeds, CVLAN, and port assignments up the resource hierarchy. + +## Access Domain + +A copper-network equivalent covering DSL access services — Cable, Card, Path, and Shelf. Explore `lib/access/` for the domain model. ## Installation @@ -32,13 +42,6 @@ end You need [Neo4j](https://github.com/neo4j/neo4j) available. We recommend the Neo4j Community 5 latest, available at [Neo4j Deploymnent Centre](https://neo4j.com/deployment-center/) which can be installed locally. You can also configure connection to a cloud based database service such as [Neo4j AuraDB](https://neo4j.com/product/auradb/). -## Tutorial - -Click the **Run in Livebook** badge above to open the interactive tutorial, or find it at [diffo_example.livemd](diffo_example.livemd). - -The diffo_example livebook walks through provisioning a complete NBN Ethernet access circuit — NTD, UNI, AVC, CVC, NNI Group, and NNI — showing how the `mine` actions propagate technology, speeds, CVLAN, and port assignments up the resource hierarchy. - - ## Contributions Contributions are welcome, please start with an [issue](https://github.com/diffo-dev/diffo_example/issues) diff --git a/diffo_example.livemd b/documentation/domains/diffo_example_nbn.livemd similarity index 69% rename from diffo_example.livemd rename to documentation/domains/diffo_example_nbn.livemd index 830be14..df77a32 100644 --- a/diffo_example.livemd +++ b/documentation/domains/diffo_example_nbn.livemd @@ -10,20 +10,24 @@ SPDX-License-Identifier: MIT Mix.install( [ {:diffo_example, "~> 0.2.0"}, + {:kino, "~> 0.14"}, {:req, "~> 0.5"} ], - config: [ - bolty: [{Bolt, [ - uri: "bolt://localhost:7687", - auth: [username: "neo4j", password: "password"], - user_agent: "diffoExampleLivebook/1", - pool_size: 15, - max_overflow: 3, - prefix: :default, - name: Bolt, - log: false, - log_hex: false - ]}] + config: [ + bolty: [ + {Bolt, + [ + uri: "bolt://localhost:7687", + auth: [username: "neo4j", password: "password"], + user_agent: "diffoExampleLivebook/1", + pool_size: 15, + max_overflow: 3, + prefix: :default, + name: Bolt, + log: false, + log_hex: false + ]} + ] ], consolidate_protocols: false ) @@ -67,42 +71,28 @@ It is helpful to have a Neo4j browser open locally, typically at http://localhos AshNeo4j.Neo4jHelper.delete_all() ``` -## Setup Aliases - -```elixir -require Ash.Query -alias DiffoExample.Nbn -alias DiffoExample.Nbn.NbnEthernet -alias DiffoExample.Nbn.Uni -alias DiffoExample.Nbn.Avc -alias DiffoExample.Nbn.Ntd -alias DiffoExample.Nbn.Cvc -alias DiffoExample.Nbn.NniGroup -alias DiffoExample.Nbn.Nni -alias DiffoExample.Nbn.Technology -alias DiffoExample.Nbn.Speeds -import Jason, only: [encode: 2] -``` - -## About NBN +## About NBN Co NBN (National Broadband Network) is Australia's wholesale fixed-line access network, operated by NBN Co. It provides standardised access products to Retail Service Providers (RSPs), who in turn deliver internet and other services to end customers. +For the purpose of this example we are going to refer to a simplified, and re-imagined NBN Co as NBN. + An RSP typically combines: * An **NBN Ethernet** access circuit (UNI + AVC) at the customer premises — the access and aggregation layer modelled in this domain * A **home gateway** device installed at the UNI, which provides the customer's LAN, Wi-Fi, and sometimes voice * Transport, aggregation, and edge infrastructure connecting the NNI to the RSP's network and on to the internet -NBN Co connects the customer premises to the RSP's network via a Point of Interconnect (POI). The NNI sits at the POI, grouped into NNI Groups. AVCs carrying customer traffic are aggregated onto a CVC, which terminates at the NNI Group. The RSP purchases CVC capacity to carry the aggregate traffic of its customers at that POI. +NBN connects the customer premises to the RSP's network via a Point of Interconnect (POI). The NNI sits at the POI, grouped into NNI Groups. AVCs carrying customer traffic are aggregated onto a CVC, which terminates at the NNI Group. The RSP purchases CVC capacity to carry the aggregate traffic of its customers at that POI. -NBN is delivered over several access technologies — FTTP, FTTN, FTTB, FTTC, HFC, Fixed Wireless, and Satellite — which determine which bandwidth profiles and speeds are available to a given premises. +NBN delivers over several access technologies — FTTP, FTTN, FTTB, FTTC, HFC, Fixed Wireless, and Satellite — which determine which bandwidth profiles and speeds are available to a given premises. -## Technology and Speeds +## NBN Ethernet Technology and Speeds The NBN domain defines Technology as an Ash Enum covering all NBN access types: ```elixir +alias DiffoExample.Nbn.{Technology,Speeds} Technology.values() ``` @@ -125,21 +115,51 @@ Speeds.speeds(:wireless_superfast, :FixedWireless) Speeds.speeds(:home_fast, :FixedWireless) ``` -## Building the Network Hinterland +## Multi-tenancy + +Each RSP operates in isolation — they can only see and manage the resources they own. This multi-tenancy is enforced at the Ash policy layer: every NBN resource is stamped with the owning RSP's id at creation, and subsequent reads, updates, and destroys are scoped to the record owner. + +Select the RSP you want to operate as for the rest of this livebook. All resources you build will be owned by that RSP and isolated from resources owned by others. + +```elixir +alias DiffoExample.Nbn +alias DiffoExample.Nbn.Rsp +import Jason, only: [encode: 2] +DiffoExample.Nbn.Initializer.init() +rsps = Nbn.list_rsps!() +Kino.DataTable.new(rsps, keys: [:epid, :name, :short_name, :state]) +``` + +```elixir +rsp_input = Kino.Input.select( + "Operate as RSP", + Enum.map(rsps, fn rsp -> {rsp.name, Atom.to_string(rsp.short_name)} end) +) +``` + +```elixir +actor = Enum.find(rsps, fn rsp -> rsp.name == Kino.Input.read(rsp_input) end) +actor +``` + +## Maintaining Shareable Resources + +As an RSP we need maintain some shareable network resources: NNI, NNI Group, and CVC. -Before we can provision an NBN Ethernet access we need the shared network resources: NNI, NNI Group, and CVC. +We'll need these everywhere we operate, in advance of and sufficient for all the NBN Ethernet Accesses we have. We'll just build one of each right now. -Build an NNI — the physical interconnect between the RSP and NBN Co: +Build an NNI — the physical interconnect between the RSP and NBN: ```elixir -nni = Nbn.build_nni!(%{}) +alias DiffoExample.Nbn.{Nni, NniGroup, CVC} +nni = Nbn.build_nni!(%{}, actor: actor) nni |> Jason.encode!(pretty: true) |> IO.puts ``` Build an NNI Group — a logical grouping of NNIs at a point of interconnect: ```elixir -nni_group = Nbn.build_nni_group!(%{}) +nni_group = Nbn.build_nni_group!(%{}, actor: actor) nni_group |> Jason.encode!(pretty: true) |> IO.puts ``` @@ -148,30 +168,33 @@ Define the NNI Group with an SVLAN assignment and relate the NNI: ```elixir nni_group = Nbn.define_nni_group!(nni_group, %{ characteristic_value_updates: [nni_group: [svlan: 100]] -}) +}, actor: actor) nni_group = Nbn.relate_nni_group!(nni_group, %{ relationships: [%Diffo.Provider.Instance.Relationship{id: nni.id, alias: :nni, type: :isAssigned}] -}) +}, actor: actor) nni_group |> Jason.encode!(pretty: true) |> IO.puts ``` Build a CVC — the aggregation virtual circuit that terminates at the NNI Group: ```elixir -cvc = Nbn.build_cvc!(%{}) +cvc = Nbn.build_cvc!(%{}, actor: actor) cvc = Nbn.relate_cvc!(cvc, %{ relationships: [%Diffo.Provider.Instance.Relationship{id: nni_group.id, alias: :nni_group, type: :isAssigned}] -}) +}, actor: actor) cvc |> Jason.encode!(pretty: true) |> IO.puts ``` -## Provisioning an NBN Ethernet Access +## Provisioning NBN Ethernet + +For each customer site we want to provide service to, we need an NBN Ethernet composite resource, involving an NTD, UNI, AVC and CVC. -With the hinterland in place we can provision a customer-facing NBN Ethernet access. +The NTD is NBN infrastructure — built and managed by NBN, visible to any RSP. It may not exist at a new or existing customer site, so may be built on demand by NBN. Build an NTD — the device installed at the customer premises: ```elixir +alias DiffoExample.Nbn.{Ntd, Uni, Avc, NbnEthernet} ntd = Nbn.build_ntd!(%{}) ntd = Nbn.define_ntd!(ntd, %{ characteristic_value_updates: [ntd: [technology: :FTTP, ports: [1, 2, 3, 4]]] @@ -203,28 +226,28 @@ uni |> Jason.encode!(pretty: true) |> IO.puts Build an AVC and assign it a CVLAN from the CVC: ```elixir -avc = Nbn.build_avc!(%{}) +avc = Nbn.build_avc!(%{}, actor: actor) avc = Nbn.define_avc!(avc, %{ characteristic_value_updates: [avc: [bandwidth_profile: :home_ultrafast]] -}) +}, actor: actor) cvc = Nbn.assign_cvlan!(cvc, %{ assignment: %Assignment{assignee_id: avc.id, operation: :auto_assign} -}) -avc = Nbn.mine_avc!(avc, %{}) +}, actor: actor) +avc = Nbn.mine_avc!(avc, %{}, actor: actor) avc |> Jason.encode!(pretty: true) |> IO.puts ``` Now build the top-level NBN Ethernet access and relate it to both the UNI and AVC: ```elixir -pri = Nbn.build_nbn_ethernet!(%{}) +pri = Nbn.build_nbn_ethernet!(%{}, actor: actor) pri = Nbn.relate_nbn_ethernet!(pri, %{ relationships: [ %Diffo.Provider.Instance.Relationship{id: uni.id, alias: :uni, type: :isAssigned}, %Diffo.Provider.Instance.Relationship{id: avc.id, alias: :avc, type: :isAssigned} ] -}) -pri = Nbn.mine_nbn_ethernet!(pri, %{}) +}, actor: actor) +pri = Nbn.mine_nbn_ethernet!(pri, %{}, actor: actor) pri |> Jason.encode!(pretty: true) |> IO.puts ``` @@ -251,19 +274,19 @@ The NBN domain exposes a JSON API via `Plug.Cowboy` on port 4000. Start the serv First check the catalog — all NBN specifications are initialised on startup: ```elixir -Req.get!("http://localhost:4000/catalog").body |> Jason.encode!(pretty: true) |> IO.puts() +Req.get!("http://localhost:4000/catalog", decode_body: false).body |> IO.puts() ``` Now retrieve all NBN Ethernet instances: ```elixir -Req.get!("http://localhost:4000/nbnEthernet").body |> Jason.encode!(pretty: true) |> IO.puts() +Req.get!("http://localhost:4000/nbnEthernet", decode_body: false).body |> IO.puts() ``` Or fetch the one we provisioned above by id: ```elixir -Req.get!("http://localhost:4000/nbnEthernet/#{pri.id}").body |> Jason.encode!(pretty: true) |> IO.puts() +Req.get!("http://localhost:4000/nbnEthernet/#{pri.id}", decode_body: false).body |> IO.puts() ``` ## What Next? diff --git a/documentation/domains/nbn.md b/documentation/domains/nbn.md new file mode 100644 index 0000000..eb3bcc0 --- /dev/null +++ b/documentation/domains/nbn.md @@ -0,0 +1,23 @@ + + +# The NBN Domain + +## The Perentie Ecosystem + +NBN Co operates as **Perentie** — Australia's largest monitor lizard, ancient and continent-wide. Perentie owns the territory. It does not compete with the animals moving through its country; it simply defines the ground they all walk on. + +The RSPs are the spirit animals of the ecosystem, each finding their niche in Perentie's range: + +| RSP | Spirit Animal | Inspiration | +| ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| Wedge-tail Telecom | Wedge-tailed Eagle | Australia's apex aerial predator — dominant, territorial, commands every landscape it surveys | +| Quokka Connect | Quokka | Famously friendly, genuinely Australian, radiates good energy — operates in WA under bilateral agreement with Perentie | +| Ibis Telecom | White Ibis | Beloved in spite of its reputation, scrappy, surprisingly capable | +| Taipan Group | Taipan | Carries the TPG initials; fast, precise, not to be underestimated | +| Echidna Networks | Echidna | Prickly on the surface, uniquely capable beneath it | +| Dugong Digital | Dugong | Slow and steady, but still very much alive | +| Lyrebird | Lyrebird | Mimics everything, loops back on itself, endlessly clever | diff --git a/lib/diffo_example/application.ex b/lib/diffo_example/application.ex index 8bca8f1..0de7553 100644 --- a/lib/diffo_example/application.ex +++ b/lib/diffo_example/application.ex @@ -9,10 +9,13 @@ defmodule DiffoExample.Application do @impl true def start(_type, _args) do - children = [ - {Plug.Cowboy, scheme: :http, plug: DiffoExample.Nbn.Router, options: [port: 4000]}, - {Task, &DiffoExample.Nbn.Initializer.init/0} - ] + children = + [ + {Task, &DiffoExample.Nbn.Initializer.init/0} + ] ++ + if Mix.env() == :test, + do: [], + else: [{Plug.Cowboy, scheme: :http, plug: DiffoExample.Nbn.Router, options: [port: 4000]}] Supervisor.start_link(children, strategy: :one_for_one, name: DiffoExample.Supervisor) end diff --git a/lib/nbn/changes/set_rsp_id.ex b/lib/nbn/changes/set_rsp_id.ex new file mode 100644 index 0000000..c03dc03 --- /dev/null +++ b/lib/nbn/changes/set_rsp_id.ex @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Changes.SetRspId do + use Ash.Resource.Change + + def change(changeset, _opts, %{actor: %{id: id}}) do + Ash.Changeset.force_change_attribute(changeset, :rsp_id, id) + end + + def change(changeset, _opts, _context), do: changeset +end diff --git a/lib/nbn/checks/no_actor.ex b/lib/nbn/checks/no_actor.ex new file mode 100644 index 0000000..e40e12c --- /dev/null +++ b/lib/nbn/checks/no_actor.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Checks.NoActor do + @moduledoc false + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "no actor present (internal Perentie call)" + + @impl true + def match?(nil, _context, _opts), do: true + def match?(_actor, _context, _opts), do: false +end diff --git a/lib/nbn/checks/owned_by_actor.ex b/lib/nbn/checks/owned_by_actor.ex new file mode 100644 index 0000000..f53f0fa --- /dev/null +++ b/lib/nbn/checks/owned_by_actor.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Checks.OwnedByActor do + @moduledoc false + use Ash.Policy.FilterCheck + + @impl true + def describe(_opts), do: "actor owns resource (rsp_id matches actor id)" + + @impl true + def filter(actor, _context, _opts) do + case actor do + %{id: id} -> [rsp_id: id] + _ -> false + end + end +end diff --git a/lib/nbn/initializer.ex b/lib/nbn/initializer.ex index d261b87..13b80e5 100644 --- a/lib/nbn/initializer.ex +++ b/lib/nbn/initializer.ex @@ -6,14 +6,26 @@ defmodule DiffoExample.Nbn.Initializer do @moduledoc """ Diffo - TMF Service and Resource Management with a difference - Initializes the NBN domain's specifications in the catalog on application startup, - so the catalog is populated before any instances are built. + Initializes the NBN domain on application startup: + - upserts all resource specifications into the catalog + - seeds RSP records in historical EPID sequence """ alias Diffo.Provider.Instance.Specification + alias DiffoExample.Nbn + + @rsps [ + %{name: "Wedge-tail Telecom", short_name: :wedgetail, epid: "0001"}, + %{name: "Quokka Connect", short_name: :quokka, epid: "0002"}, + %{name: "Ibis Telecom", short_name: :ibis, epid: "0003"}, + %{name: "Taipan Group", short_name: :taipan, epid: "0004"}, + %{name: "Echidna Networks", short_name: :echidna, epid: "0005"}, + %{name: "Dugong Digital", short_name: :dugong, epid: "0006"}, + %{name: "Lyrebird", short_name: :lyrebird, epid: "0007"} + ] def init do - DiffoExample.Nbn + Nbn |> Ash.Domain.Info.resources() |> Enum.each(fn module -> try do @@ -22,5 +34,26 @@ defmodule DiffoExample.Nbn.Initializer do _ -> :ok end end) + + seed_rsps() + end + + defp seed_rsps do + Enum.each(@rsps, fn attrs -> + try do + case Nbn.get_rsp_by_epid(attrs.epid) do + {:ok, nil} -> seed_rsp(attrs) + {:ok, _} -> :ok + {:error, _} -> seed_rsp(attrs) + end + rescue + e -> require Logger; Logger.error("Exception seeding RSP #{attrs.epid}: #{inspect(e)}") + end + end) + end + + defp seed_rsp(attrs) do + {:ok, rsp} = Nbn.create_rsp(attrs) + {:ok, _} = Nbn.activate_rsp(rsp) end end diff --git a/lib/nbn/nbn.ex b/lib/nbn/nbn.ex index b739947..43d4bfa 100644 --- a/lib/nbn/nbn.ex +++ b/lib/nbn/nbn.ex @@ -23,6 +23,7 @@ defmodule DiffoExample.Nbn do alias DiffoExample.Nbn.Cvc alias DiffoExample.Nbn.NniGroup alias DiffoExample.Nbn.Nni + alias DiffoExample.Nbn.Rsp domain do description "An example showing how TMF Resources for a fictional NBN domain can be extended from the Provider domain" @@ -97,6 +98,10 @@ defmodule DiffoExample.Nbn do delete :destroy end + base_route "/rsp", Rsp do + get :read + end + end end @@ -157,5 +162,15 @@ defmodule DiffoExample.Nbn do define :relate_nni, action: :relate end + resource Rsp do + define :list_rsps, action: :list + define :get_rsp_by_epid, action: :read, get_by: :epid + define :get_rsp_by_short_name, action: :read, get_by: :short_name + define :create_rsp, action: :create + define :activate_rsp, action: :activate + define :suspend_rsp, action: :suspend + define :deactivate_rsp, action: :deactivate + end + end end diff --git a/lib/nbn/resources/avc.ex b/lib/nbn/resources/avc.ex index 9924932..5f49871 100644 --- a/lib/nbn/resources/avc.ex +++ b/lib/nbn/resources/avc.ex @@ -22,7 +22,8 @@ defmodule DiffoExample.Nbn.Avc do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "avc" @@ -46,6 +47,14 @@ defmodule DiffoExample.Nbn.Avc do characteristic :cvc, DiffoExample.Nbn.CvcValue end + attributes do + attribute :rsp_id, :uuid do + description "the owning RSP's id — nil for Perentie-managed infrastructure" + allow_nil? true + public? true + end + end + actions do create :build do description "creates a new AVC resource instance" @@ -67,6 +76,8 @@ defmodule DiffoExample.Nbn.Avc do ActionHelper.build_after(changeset, result, Nbn, :get_avc_by_id) end) + change DiffoExample.Nbn.Changes.SetRspId + change load [:href] upsert? false end @@ -121,4 +132,6 @@ defmodule DiffoExample.Nbn.Avc do Ash.Changeset.force_set_argument(changeset, :characteristic_value_updates, avc: [cvlan]) end + + use DiffoExample.Nbn.RspOwnership end diff --git a/lib/nbn/resources/cvc.ex b/lib/nbn/resources/cvc.ex index 81d67a2..e6b8215 100644 --- a/lib/nbn/resources/cvc.ex +++ b/lib/nbn/resources/cvc.ex @@ -24,7 +24,8 @@ defmodule DiffoExample.Nbn.Cvc do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "cvc" @@ -50,6 +51,14 @@ defmodule DiffoExample.Nbn.Cvc do characteristic :cvlans, Diffo.Provider.AssignableValue end + attributes do + attribute :rsp_id, :uuid do + description "the owning RSP's id — nil for Perentie-managed infrastructure" + allow_nil? true + public? true + end + end + actions do create :build do description "creates a new CVC resource instance" @@ -71,6 +80,8 @@ defmodule DiffoExample.Nbn.Cvc do ActionHelper.build_after(changeset, result, Nbn, :get_cvc_by_id) end) + change DiffoExample.Nbn.Changes.SetRspId + change load [:href] upsert? false end @@ -128,6 +139,8 @@ defmodule DiffoExample.Nbn.Cvc do DiffoExample.Nbn.Util.identifier("CVC") end + use DiffoExample.Nbn.RspOwnership + # mines related resource to characteristics def mine_related(changeset, _context) when is_struct(changeset, Ash.Changeset) do reverse_relationships = Ash.Changeset.get_attribute(changeset, :reverse_relationships) diff --git a/lib/nbn/resources/nbn_ethernet.ex b/lib/nbn/resources/nbn_ethernet.ex index 26e37ca..d5c2ec2 100644 --- a/lib/nbn/resources/nbn_ethernet.ex +++ b/lib/nbn/resources/nbn_ethernet.ex @@ -23,7 +23,8 @@ defmodule DiffoExample.Nbn.NbnEthernet do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "nbnEthernet" @@ -50,6 +51,14 @@ defmodule DiffoExample.Nbn.NbnEthernet do # end end + attributes do + attribute :rsp_id, :uuid do + description "the owning RSP's id — nil for Perentie-managed infrastructure" + allow_nil? true + public? true + end + end + actions do create :build do description "creates a new NBN Ethernet access resource instance" @@ -71,6 +80,8 @@ defmodule DiffoExample.Nbn.NbnEthernet do ActionHelper.build_after(changeset, result, Nbn, :get_nbn_ethernet_by_id) end) + change DiffoExample.Nbn.Changes.SetRspId + change load [:href] upsert? false end @@ -165,4 +176,6 @@ defmodule DiffoExample.Nbn.NbnEthernet do (Atom.to_string(alias) <> "id") |> String.to_atom() end + + use DiffoExample.Nbn.RspOwnership end diff --git a/lib/nbn/resources/nni.ex b/lib/nbn/resources/nni.ex index e9b84f6..a3682ea 100644 --- a/lib/nbn/resources/nni.ex +++ b/lib/nbn/resources/nni.ex @@ -23,7 +23,8 @@ defmodule DiffoExample.Nbn.Nni do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "nni" @@ -46,6 +47,14 @@ defmodule DiffoExample.Nbn.Nni do characteristic :nni, DiffoExample.Nbn.NniValue end + attributes do + attribute :rsp_id, :uuid do + description "the owning RSP's id — nil for Perentie-managed infrastructure" + allow_nil? true + public? true + end + end + actions do create :build do description "creates a new NNI resource instance" @@ -67,6 +76,8 @@ defmodule DiffoExample.Nbn.Nni do ActionHelper.build_after(changeset, result, Nbn, :get_nni_by_id) end) + change DiffoExample.Nbn.Changes.SetRspId + change load [:href] upsert? false end @@ -97,4 +108,6 @@ defmodule DiffoExample.Nbn.Nni do DiffoExample.Nbn.Util.identifier("NNI") end end + + use DiffoExample.Nbn.RspOwnership end diff --git a/lib/nbn/resources/nni_group.ex b/lib/nbn/resources/nni_group.ex index 9e89a33..7f60ac1 100644 --- a/lib/nbn/resources/nni_group.ex +++ b/lib/nbn/resources/nni_group.ex @@ -25,7 +25,8 @@ defmodule DiffoExample.Nbn.NniGroup do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "nniGroup" @@ -49,6 +50,14 @@ defmodule DiffoExample.Nbn.NniGroup do characteristic :svlans, Diffo.Provider.AssignableValue end + attributes do + attribute :rsp_id, :uuid do + description "the owning RSP's id — nil for Perentie-managed infrastructure" + allow_nil? true + public? true + end + end + actions do create :build do description "creates a new NNI Group resource instance" @@ -68,6 +77,8 @@ defmodule DiffoExample.Nbn.NniGroup do ActionHelper.build_after(changeset, result, Nbn, :get_nni_group_by_id) end) + change DiffoExample.Nbn.Changes.SetRspId + change load [:href] upsert? false end @@ -105,4 +116,6 @@ defmodule DiffoExample.Nbn.NniGroup do end) end end + + use DiffoExample.Nbn.RspOwnership end diff --git a/lib/nbn/resources/ntd.ex b/lib/nbn/resources/ntd.ex index c7a66e5..a7a4da6 100644 --- a/lib/nbn/resources/ntd.ex +++ b/lib/nbn/resources/ntd.ex @@ -24,7 +24,8 @@ defmodule DiffoExample.Nbn.Ntd do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "ntd" @@ -110,4 +111,18 @@ defmodule DiffoExample.Nbn.Ntd do def identifier() do DiffoExample.Nbn.Util.identifier("NTD") end + + policies do + bypass DiffoExample.Nbn.Checks.NoActor do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + end end diff --git a/lib/nbn/resources/rsp.ex b/lib/nbn/resources/rsp.ex new file mode 100644 index 0000000..1f4fd66 --- /dev/null +++ b/lib/nbn/resources/rsp.ex @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.Rsp do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Rsp - Retail Service Provider + + An RSP is a licensed provider operating within the Perentie ecosystem. + Each RSP is assigned an EPID (four-digit regulator-assigned identifier) + and a short_name atom used as their actor identity for authorisation. + """ + + alias DiffoExample.Nbn + + use Ash.Resource, + domain: Nbn, + data_layer: AshNeo4j.DataLayer, + authorizers: [Ash.Policy.Authorizer], + extensions: [AshStateMachine, AshJason.Resource, AshJsonApi.Resource] + + neo4j do + label :Rsp + end + + json_api do + type "rsp" + end + + jason do + pick [:id, :name, :short_name, :epid, :state] + compact true + end + + state_machine do + initial_states [:inactive] + default_initial_state :inactive + state_attribute :state + + transitions do + transition action: :activate, from: [:inactive, :suspended], to: :active + transition action: :suspend, from: :active, to: :suspended + transition action: :deactivate, from: [:active, :suspended], to: :inactive + end + end + + attributes do + attribute :id, :uuid do + primary_key? true + allow_nil? false + public? true + default &Ash.UUID.generate/0 + source :uuid + end + + attribute :name, :string do + description "the RSP's registered trading name" + allow_nil? false + public? true + end + + attribute :short_name, :atom do + description "atom identifier used as the actor for authorisation" + allow_nil? false + public? true + end + + attribute :epid, :string do + description "four-digit regulator-assigned provider identifier, in historical sequence" + allow_nil? false + public? true + constraints [match: ~r/^\d{4}$/] + end + + attribute :state, :atom do + allow_nil? false + default :inactive + public? true + constraints [one_of: [:active, :suspended, :inactive]] + end + + create_timestamp :created_at + update_timestamp :updated_at + end + + actions do + defaults [:destroy] + + read :read do + primary? true + end + + read :list do + prepare build(sort: [epid: :asc]) + end + + create :create do + accept [:name, :short_name, :epid] + upsert? true + upsert_identity :unique_epid + end + + update :activate do + require_atomic? false + change transition_state(:active) + end + + update :suspend do + require_atomic? false + change transition_state(:suspended) + end + + update :deactivate do + require_atomic? false + change transition_state(:inactive) + end + end + + identities do + identity :unique_epid, [:epid] + identity :unique_name, [:name] + identity :unique_short_name, [:short_name] + end + + policies do + bypass DiffoExample.Nbn.Checks.NoActor do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + end + + field_policies do + field_policy :state do + authorize_if DiffoExample.Nbn.Checks.NoActor + authorize_if actor_attribute_equals(:role, :admin) + authorize_if expr(^actor(:id) == id) + end + + field_policy :* do + authorize_if always() + end + end +end diff --git a/lib/nbn/resources/uni.ex b/lib/nbn/resources/uni.ex index 6910cb4..5ddf015 100644 --- a/lib/nbn/resources/uni.ex +++ b/lib/nbn/resources/uni.ex @@ -24,7 +24,8 @@ defmodule DiffoExample.Nbn.Uni do use Ash.Resource, fragments: [BaseInstance], domain: Nbn, - extensions: [AshJsonApi.Resource] + extensions: [AshJsonApi.Resource], + authorizers: [Ash.Policy.Authorizer] json_api do type "uni" @@ -128,4 +129,18 @@ defmodule DiffoExample.Nbn.Uni do uni: [port, technology] ) end + + policies do + bypass DiffoExample.Nbn.Checks.NoActor do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + end end diff --git a/lib/nbn/rsp_ownership.ex b/lib/nbn/rsp_ownership.ex new file mode 100644 index 0000000..dfcfe6d --- /dev/null +++ b/lib/nbn/rsp_ownership.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.RspOwnership do + @moduledoc """ + Shared RSP ownership policies for NBN resources. + + Injects the standard three-tier policy into any RSP-owned resource: + - nil actor → bypass (internal Perentie calls) + - admin role → bypass + - create with RSP actor → allowed (rsp_id stamped by SetRspId) + - read/update/destroy → RSP can only access its own records + """ + + defmacro __using__(_opts) do + quote do + policies do + bypass DiffoExample.Nbn.Checks.NoActor do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:create) do + authorize_if always() + end + + policy action_type([:read, :update, :destroy]) do + authorize_if DiffoExample.Nbn.Checks.OwnedByActor + end + end + end + end +end diff --git a/mix.exs b/mix.exs index 614a1e5..31c6573 100644 --- a/mix.exs +++ b/mix.exs @@ -59,8 +59,14 @@ defmodule DiffoExample.MixProject do logo: "logos/diffo.jpg", extras: [ "README.md": [title: "Guide"], - "diffo_example.livemd": [title: "Livebook Tutorial"], + "documentation/domains/diffo_example_nbn.livemd": [title: "NBN Livebook"], + "documentation/domains/nbn.md": [title: "The NBN Domain"], "LICENSES/MIT.md": [title: "License"] + ], + groups_for_extras: [ + Domains: ~r"documentation/domains", + "How To": ~r"documentation/how_to", + DSLs: ~r"documentation/dsls" ] ] end @@ -84,6 +90,8 @@ defmodule DiffoExample.MixProject do {:ash_json_api, "~> 1.6"}, {:plug_cowboy, "~> 2.7"}, {:req, "~> 0.5", only: [:dev, :test]}, + {:picosat_elixir, "~> 0.2.0"}, + {:simple_sat, ">= 0.0.0"}, {:igniter, "~> 0.6", only: [:dev, :test]}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index 233d094..136c6c6 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,7 @@ "diffo": {:hex, :diffo, "0.2.0", "ac07bb5ea92d765601fba3e61e8a5dac5c3c7f18b3a55bcf3019a574fda03d65", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_jason, "~> 3.0", [hex: :ash_jason, repo: "hexpm", optional: false]}, {:ash_neo4j, "~> 0.3.1", [hex: :ash_neo4j, repo: "hexpm", optional: false]}, {:ash_outstanding, "~> 0.2.3", [hex: :ash_outstanding, repo: "hexpm", optional: false]}, {:ash_state_machine, "~> 0.2.12", [hex: :ash_state_machine, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm", "2a140d9e427e30b06b29a04eeafec8b98d7acfeaffdbfa06cf6c152998302503"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, @@ -39,6 +40,7 @@ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, @@ -46,6 +48,7 @@ "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"}, "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, diff --git a/test/nbn/rsp_test.exs b/test/nbn/rsp_test.exs new file mode 100644 index 0000000..e009225 --- /dev/null +++ b/test/nbn/rsp_test.exs @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.RspTest do + @moduledoc false + use ExUnit.Case + alias DiffoExample.Nbn + alias DiffoExample.Nbn.Rsp + + setup_all do + AshNeo4j.BoltyHelper.start() + end + + setup do + on_exit(fn -> + AshNeo4j.Neo4jHelper.delete_all() + end) + end + + defp create_rsp(attrs) do + {:ok, rsp} = Nbn.create_rsp(attrs) + {:ok, rsp} = Nbn.activate_rsp(rsp) + rsp + end + + describe "RSP resource" do + test "create and activate an RSP" do + {:ok, rsp} = Nbn.create_rsp(%{name: "Wedge-tail Telecom", short_name: :wedgetail, epid: "8001"}) + + assert is_struct(rsp, Rsp) + assert rsp.state == :inactive + assert rsp.epid == "8001" + assert rsp.short_name == :wedgetail + + {:ok, rsp} = Nbn.activate_rsp(rsp) + assert rsp.state == :active + end + + test "RSP state machine: activate → suspend → deactivate" do + {:ok, rsp} = Nbn.create_rsp(%{name: "Wedge-tail Telecom", short_name: :wedgetail, epid: "8001"}) + + {:ok, rsp} = Nbn.activate_rsp(rsp) + assert rsp.state == :active + + {:ok, rsp} = Nbn.suspend_rsp(rsp) + assert rsp.state == :suspended + + {:ok, rsp} = Nbn.deactivate_rsp(rsp) + assert rsp.state == :inactive + end + + test "epid must be exactly 4 digits" do + assert {:error, _} = Nbn.create_rsp(%{name: "Bad RSP", short_name: :bad, epid: "123"}) + assert {:error, _} = Nbn.create_rsp(%{name: "Bad RSP", short_name: :bad, epid: "12345"}) + assert {:error, _} = Nbn.create_rsp(%{name: "Bad RSP", short_name: :bad, epid: "abcd"}) + end + + test "get RSP by short_name" do + create_rsp(%{name: "Wedge-tail Telecom", short_name: :wedgetail, epid: "8001"}) + + {:ok, rsp} = Nbn.get_rsp_by_short_name(:wedgetail) + assert rsp.short_name == :wedgetail + assert rsp.epid == "8001" + end + + test "get RSP by epid" do + create_rsp(%{name: "Quokka Connect", short_name: :quokka, epid: "8002"}) + + {:ok, rsp} = Nbn.get_rsp_by_epid("8002") + assert rsp.short_name == :quokka + end + end + + describe "RSP multi-tenancy" do + setup do + wedgetail = create_rsp(%{name: "Wedge-tail Telecom", short_name: :wedgetail, epid: "8001"}) + quokka = create_rsp(%{name: "Quokka Connect", short_name: :quokka, epid: "8002"}) + %{wedgetail: wedgetail, quokka: quokka} + end + + test "build stamps rsp_id from actor", %{wedgetail: wedgetail} do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + assert resource.rsp_id == wedgetail.id + end + + test "build without actor leaves rsp_id nil" do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}) + assert is_nil(resource.rsp_id) + end + + test "RSP can read its own resource", %{wedgetail: wedgetail} do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + assert {:ok, found} = Nbn.get_nbn_ethernet_by_id(resource.id, actor: wedgetail) + assert found.id == resource.id + end + + test "RSP cannot read another RSP's resource", %{wedgetail: wedgetail, quokka: quokka} do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + assert {:error, _} = Nbn.get_nbn_ethernet_by_id(resource.id, actor: quokka) + end + + test "RSP cannot update another RSP's resource", %{wedgetail: wedgetail, quokka: quokka} do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + + assert {:error, %Ash.Error.Forbidden{}} = + Nbn.define_nbn_ethernet(resource, %{characteristic_value_updates: []}, actor: quokka) + end + + test "nil actor (internal call) can read any RSP's resource", %{wedgetail: wedgetail} do + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + assert {:ok, found} = Nbn.get_nbn_ethernet_by_id(resource.id) + assert found.id == resource.id + end + + test "admin actor can read any RSP's resource", %{wedgetail: wedgetail} do + admin = %{id: Ash.UUID.generate(), role: :admin} + {:ok, resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + assert {:ok, found} = Nbn.get_nbn_ethernet_by_id(resource.id, actor: admin) + assert found.id == resource.id + end + + test "two RSPs own separate resources", %{wedgetail: wedgetail, quokka: quokka} do + {:ok, wt_resource} = Nbn.build_nbn_ethernet(%{}, actor: wedgetail) + {:ok, qq_resource} = Nbn.build_nbn_ethernet(%{}, actor: quokka) + + assert wt_resource.rsp_id == wedgetail.id + assert qq_resource.rsp_id == quokka.id + refute wt_resource.rsp_id == qq_resource.rsp_id + end + end +end From 439c5ebfc7181cb280322cd6f64d6004133d2c1c Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 26 Apr 2026 09:47:52 +0930 Subject: [PATCH 4/4] update CHANGELOG for v0.2.0 --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b033778..16391cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,16 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline ### Fixes: * fixed relationship enrichment inconsistent across neo4j versions -## [v0.2.0](https://github.com/diffo-dev/diffo/compare/v0.0.4..v0.2.0) (2026-04-24) +## [v0.2.0](https://github.com/diffo-dev/diffo/compare/v0.0.4..v0.2.0) (2026-04-26) ### Maintenance: * updated to diffo 0.2.0 ### Features: -* new NBN domain modelling NBN Ethernet access and constituent resources (UNI, AVC, NTD, CVC, NNI Group, NNI), JSON API and livebook \ No newline at end of file +* new NBN domain modelling NBN Ethernet access and constituent resources (UNI, AVC, NTD, CVC, NNI Group, NNI) +* JSON API via AshJsonApi and Plug.Cowboy +* RSP resource with AshStateMachine lifecycle (inactive/active/suspended) and Ash Policy authorisation +* RSP multi-tenancy: SetRspId change, OwnedByActor and NoActor policy checks, RspOwnership macro shared across RSP-owned resources +* NTD and UNI modelled as NBN-owned infrastructure — readable by any RSP, mutable only by internal calls +* Interactive NBN livebook with Kino RSP selector and actor-scoped provisioning flow +* NBN domain documentation including Perentie ecosystem narrative \ No newline at end of file