Skip to content

Commit dc47b0a

Browse files
authored
chore: add namespace functionality to openbao (astarte-platform#1856)
add two functions: - create_namespace/3, which creates the nested key namespace - list_namespaces/0, which lists all namespaces, and is used in tests the client now always points to the "/v1" api Signed-off-by: Francesco Noacco <francesco.noacco@secomind.com>
1 parent 4e69df4 commit dc47b0a

9 files changed

Lines changed: 380 additions & 5 deletions

File tree

apps/astarte_pairing/config/test.exs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,7 @@ config :astarte_pairing, :base_url_domain, "api.astarte.localhost"
102102
config :astarte_pairing, :base_url_port, 4003
103103
config :astarte_pairing, :base_url_protocol, :http
104104
config :astarte_pairing, :enable_credential_reuse, true
105-
106-
config :astarte_pairing, bao_authentication_mechanism: :token, bao_token: ""
105+
config :astarte_pairing, bao_authentication_mechanism: :token, bao_token: "astarte_token"
107106

108107
config :bcrypt_elixir,
109108
log_rounds: 4

apps/astarte_pairing/lib/astarte_pairing/fdo/open_bao/client.ex

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ defmodule Astarte.Pairing.FDO.OpenBao.Client do
2424
use HTTPoison.Base
2525

2626
alias Astarte.Pairing.Config
27+
alias HTTPoison.AsyncResponse
28+
alias HTTPoison.Error
29+
alias HTTPoison.Response
2730

2831
@impl true
2932
def process_request_url(url) do
30-
Config.bao_url!() <> url
33+
Config.bao_url!() <> "/v1" <> url
3134
end
3235

3336
@impl true
@@ -50,6 +53,35 @@ defmodule Astarte.Pairing.FDO.OpenBao.Client do
5053
end
5154
end
5255

56+
@doc """
57+
Issues a LIST request to the given url.
58+
59+
Returns `{:ok, response}` if the request is successful, `{:error, reason}`
60+
otherwise.
61+
62+
See `request/5` for more detailed information.
63+
"""
64+
@spec list(binary, headers, Keyword.t()) ::
65+
{:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
66+
def list(url, headers \\ [], options \\ []) do
67+
options = update_in(options, [:params], &[{"list", "true"} | &1 || []])
68+
get(url, headers, options)
69+
end
70+
71+
@doc """
72+
Issues a LIST request to the given url, raising an exception in case of
73+
failure.
74+
75+
If the request does not fail, the response is returned.
76+
77+
See `request!/5` for more detailed information.
78+
"""
79+
@spec list!(binary, headers, Keyword.t()) :: Response.t() | AsyncResponse.t()
80+
def list!(url, headers \\ [], options \\ []) do
81+
options = update_in(options, [:params], &[{"list", "true"} | &1 || []])
82+
get!(url, headers, options)
83+
end
84+
5385
defp maybe_add_default_token(headers, token) do
5486
# If the token is not already set, add the default token
5587
case Enum.find(headers, &authentication_header?/1) do

apps/astarte_pairing/lib/astarte_pairing/fdo/open_bao/core.ex

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,116 @@ defmodule Astarte.Pairing.FDO.OpenBao.Core do
2020
@moduledoc """
2121
Implementation of function to interface with OpenBao.
2222
"""
23+
24+
alias Astarte.DataAccess.Config, as: DataAccessConfig
25+
alias Astarte.Pairing.FDO.OpenBao.Client
26+
alias HTTPoison.Response
27+
28+
require Logger
29+
30+
@doc """
31+
Returns the namespace name for the given params, represented as a list of tokens
32+
"""
33+
def namespace_tokens(realm_name, user_id, key_algorithm) do
34+
["fdo_owner_keys", instance_tokens(), realm_name, user_tokens(user_id), key_algorithm]
35+
|> List.flatten()
36+
end
37+
38+
defp instance_tokens do
39+
case DataAccessConfig.astarte_instance_id!() do
40+
"" ->
41+
"default_instance"
42+
43+
instance_id ->
44+
["instance", instance_id]
45+
end
46+
end
47+
48+
defp user_tokens(nil), do: "default_user"
49+
defp user_tokens(user_id), do: ["user_id", user_id]
50+
51+
def create_nested_namespace(namespace_tokens) do
52+
Enum.reduce_while(namespace_tokens, {:ok, ""}, fn new_namespace, {:ok, base_namespace} ->
53+
headers = [{"X-Vault-Namespace", base_namespace}]
54+
55+
case Client.post("/sys/namespaces/#{new_namespace}", "", headers) do
56+
{:ok, %HTTPoison.Response{status_code: 200}} ->
57+
new_base_namespace = base_namespace <> "/" <> new_namespace
58+
{:cont, {:ok, new_base_namespace}}
59+
60+
error ->
61+
"Error creating new namespace #{new_namespace} on #{base_namespace}: #{inspect(error)}"
62+
|> Logger.error()
63+
64+
{:halt, {:error, :namespace_creation_error}}
65+
end
66+
end)
67+
end
68+
69+
defp parse_data_key(json_str, key) do
70+
with {:ok, data} <- parse_json_data(json_str) do
71+
fetch_data_key(data, key)
72+
end
73+
end
74+
75+
defp parse_json_data(json_str) do
76+
with {:ok, map} when is_map(map) <- Jason.decode(json_str),
77+
{:ok, data} <- Map.fetch(map, "data") do
78+
{:ok, data}
79+
else
80+
_ -> {:error, {:invalid_response_body, json_str}}
81+
end
82+
end
83+
84+
defp fetch_data_key(data, key) do
85+
with :error <- Map.fetch(data, key) do
86+
{:error, {:unexpected_body_format, data}}
87+
end
88+
end
89+
90+
def list_namespaces(base_namespace \\ "", acc \\ MapSet.new()) do
91+
with {:ok, children} <- list_relative_namespaces(base_namespace) do
92+
child_namespaces = children |> Enum.map(&(base_namespace <> &1))
93+
acc = child_namespaces |> MapSet.new() |> MapSet.union(acc)
94+
95+
Enum.reduce_while(children, {:ok, acc}, &do_list_namespaces(base_namespace, &1, &2))
96+
end
97+
end
98+
99+
defp list_relative_namespaces(base_namespace) do
100+
headers = [{"X-Vault-Namespace", base_namespace}]
101+
102+
case Client.list("/sys/namespaces", headers) do
103+
{:ok, %Response{status_code: 200, body: body}} ->
104+
case parse_data_key(body, "keys") do
105+
{:ok, _keys} = ok ->
106+
ok
107+
108+
error ->
109+
Logger.warning("Error while listing namespaces: #{inspect(error)}")
110+
error
111+
end
112+
113+
{:ok, %Response{status_code: 404}} ->
114+
# Responds with 404 when there is no relative namespace
115+
{:ok, []}
116+
117+
error ->
118+
Logger.warning("Error while listing namespaces: #{inspect(error)}")
119+
error
120+
end
121+
end
122+
123+
defp do_list_namespaces(base_namespace, child, {:ok, acc}) do
124+
child_namespace = base_namespace <> child
125+
126+
case list_namespaces(child_namespace, acc) do
127+
{:ok, child_branch} ->
128+
acc = child_branch |> MapSet.new() |> MapSet.union(acc)
129+
{:cont, {:ok, acc}}
130+
131+
error ->
132+
{:halt, error}
133+
end
134+
end
23135
end

apps/astarte_pairing/lib/astarte_pairing/fdo/open_bao/open_bao.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,17 @@ defmodule Astarte.Pairing.FDO.OpenBao do
2020
@moduledoc """
2121
Functionality to interface with OpenBao.
2222
"""
23+
24+
alias Astarte.Pairing.FDO.OpenBao.Core
25+
26+
def create_namespace(realm_name, user_id \\ nil, key_algorithm) do
27+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
28+
|> Core.create_nested_namespace()
29+
end
30+
31+
def list_namespaces do
32+
with {:ok, namespaces} <- Core.list_namespaces() do
33+
{:ok, Enum.to_list(namespaces)}
34+
end
35+
end
2336
end

apps/astarte_pairing/test/astarte_pairing/fdo/open_bao/client_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ defmodule Astarte.Pairing.FDO.OpenBao.ClientTest do
3434

3535
test "always performs requests on the open bao base url", %{bao_url: bao_url} do
3636
path = "/example"
37-
expected_url = bao_url <> path
37+
expected_url = bao_url <> "/v1" <> path
3838

3939
validate_request(fn _method, url, _headers, _body, _opts ->
4040
assert url == expected_url
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
defmodule Astarte.Pairing.FDO.OpenBao.CoreTest do
20+
use ExUnit.Case, async: true
21+
use Mimic
22+
23+
alias Astarte.Pairing.FDO.OpenBao
24+
alias Astarte.Pairing.FDO.OpenBao.Core
25+
26+
import Astarte.Helpers.OpenBao
27+
28+
describe "namespace_tokens/3" do
29+
setup :namespace_tokens_setup
30+
31+
test "always starts with fdo_owner_keys", context do
32+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
33+
34+
assert ["fdo_owner_keys" | _] =
35+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
36+
end
37+
38+
test "uses default_instance for empty astarte_instance_id", context do
39+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
40+
41+
assert [_, "default_instance" | _] =
42+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
43+
end
44+
45+
@tag instance: "someinstance"
46+
test "uses nested namespaces when instance id is set", context do
47+
%{
48+
realm_name: realm_name,
49+
user_id: user_id,
50+
key_algorithm: key_algorithm,
51+
instance: instance
52+
} = context
53+
54+
assert [_, "instance", ^instance | _] =
55+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
56+
end
57+
58+
test "places realm name after instance", context do
59+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
60+
61+
assert [_, _, ^realm_name | _] =
62+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
63+
end
64+
65+
test "uses default_user for empty user id", context do
66+
%{realm_name: realm_name, key_algorithm: key_algorithm} = context
67+
68+
assert [_, _, _, "default_user" | _] =
69+
Core.namespace_tokens(realm_name, nil, key_algorithm)
70+
end
71+
72+
@tag user_id: "userid"
73+
test "uses nested namespaces when user id is set", context do
74+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
75+
76+
assert [_, _, _, "user_id", ^user_id | _] =
77+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
78+
end
79+
80+
test "ends with key algorithm", context do
81+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
82+
83+
assert [_, _, _, _, ^key_algorithm] =
84+
Core.namespace_tokens(realm_name, user_id, key_algorithm)
85+
end
86+
87+
@tag instance: "someinstance"
88+
@tag user_id: "user_id"
89+
test "produces expected result", context do
90+
%{
91+
realm_name: realm_name,
92+
user_id: user_id,
93+
key_algorithm: key_algorithm,
94+
instance: instance
95+
} = context
96+
97+
assert [
98+
"fdo_owner_keys",
99+
"instance",
100+
instance,
101+
realm_name,
102+
"user_id",
103+
user_id,
104+
key_algorithm
105+
] == Core.namespace_tokens(realm_name, user_id, key_algorithm)
106+
end
107+
end
108+
109+
describe "create_nested_namespace/1" do
110+
@describetag skip: "needs openbao in ci"
111+
112+
setup :create_nested_namespace_setup
113+
114+
test "returns the final namespace created", context do
115+
%{final_namespace: namespace, tokens: tokens} = context
116+
117+
assert {:ok, namespace} == Core.create_nested_namespace(tokens)
118+
end
119+
120+
test "creates nested namespaces", context do
121+
%{tokens: tokens, all_namespaces: namespaces} = context
122+
namespaces = MapSet.new(namespaces)
123+
124+
{:ok, _} = Core.create_nested_namespace(tokens)
125+
{:ok, fetched_namespaces} = OpenBao.list_namespaces()
126+
fetched_namespaces = MapSet.new(fetched_namespaces)
127+
128+
assert MapSet.subset?(namespaces, fetched_namespaces)
129+
end
130+
end
131+
132+
defp create_nested_namespace_setup(_context) do
133+
namespace = "/some/namespace/path"
134+
tokens = namespace |> String.split("/", trim: true)
135+
all_namespaces = ["some/", "some/namespace/", "some/namespace/path/"]
136+
137+
%{final_namespace: namespace, tokens: tokens, all_namespaces: all_namespaces}
138+
end
139+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
defmodule Astarte.Pairing.FDO.OpenBaoTest do
20+
use ExUnit.Case, async: true
21+
use Mimic
22+
23+
alias Astarte.Pairing.FDO.OpenBao
24+
alias Astarte.Pairing.FDO.OpenBao.Core
25+
26+
import Astarte.Helpers.OpenBao
27+
28+
describe "create_namespace/3" do
29+
setup :namespace_tokens_setup
30+
31+
test "calls core functions", context do
32+
%{realm_name: realm_name, user_id: user_id, key_algorithm: key_algorithm} = context
33+
34+
ref = System.unique_integer()
35+
36+
Core
37+
|> expect(:namespace_tokens, fn ^realm_name, ^user_id, ^key_algorithm -> ref end)
38+
|> expect(:create_nested_namespace, fn ^ref -> {:ok, ""} end)
39+
40+
assert {:ok, _} = OpenBao.create_namespace(realm_name, user_id, key_algorithm)
41+
end
42+
end
43+
end

0 commit comments

Comments
 (0)