clawdentity_core/registry/
admin.rs1use serde::{Deserialize, Serialize};
2
3use crate::config::{CliConfig, ConfigPathOptions, read_config, resolve_config, write_config};
4use crate::error::{CoreError, Result};
5use crate::http::blocking_client;
6
7const ADMIN_BOOTSTRAP_PATH: &str = "/v1/admin/bootstrap";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct AdminHuman {
12 pub id: String,
13 pub did: String,
14 pub display_name: String,
15 pub role: String,
16 pub status: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct AdminApiKey {
22 pub id: String,
23 pub name: String,
24 pub token: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct AdminInternalService {
30 pub id: String,
31 pub name: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AdminBootstrapResult {
37 pub human: AdminHuman,
38 pub api_key: AdminApiKey,
39 pub internal_service: AdminInternalService,
40 pub registry_url: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AdminBootstrapInput {
45 pub bootstrap_secret: String,
46 pub display_name: Option<String>,
47 pub api_key_name: Option<String>,
48 pub registry_url: Option<String>,
49}
50
51#[derive(Debug, Deserialize)]
52#[serde(rename_all = "camelCase")]
53struct AdminBootstrapPayload {
54 human: AdminHumanPayload,
55 api_key: AdminApiKeyPayload,
56 internal_service: AdminInternalServicePayload,
57}
58
59#[derive(Debug, Deserialize)]
60#[serde(rename_all = "camelCase")]
61struct AdminHumanPayload {
62 id: String,
63 did: String,
64 display_name: String,
65 role: String,
66 status: String,
67}
68
69#[derive(Debug, Deserialize)]
70#[serde(rename_all = "camelCase")]
71struct AdminApiKeyPayload {
72 id: String,
73 name: String,
74 token: String,
75}
76
77#[derive(Debug, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct AdminInternalServicePayload {
80 id: String,
81 name: String,
82}
83
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86struct ErrorEnvelope {
87 error: Option<RegistryError>,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(rename_all = "camelCase")]
92struct RegistryError {
93 message: Option<String>,
94}
95
96fn parse_non_empty(value: &str, field: &str) -> Result<String> {
97 let trimmed = value.trim();
98 if trimmed.is_empty() {
99 return Err(CoreError::InvalidInput(format!("{field} is required")));
100 }
101 Ok(trimmed.to_string())
102}
103
104fn normalize_registry_url(value: &str) -> Result<String> {
105 url::Url::parse(value.trim())
106 .map(|url| url.to_string())
107 .map_err(|_| CoreError::InvalidUrl {
108 context: "registryUrl",
109 value: value.to_string(),
110 })
111}
112
113fn to_request_url(registry_url: &str) -> Result<String> {
114 let base = if registry_url.ends_with('/') {
115 registry_url.to_string()
116 } else {
117 format!("{registry_url}/")
118 };
119 let joined = url::Url::parse(&base)
120 .map_err(|_| CoreError::InvalidUrl {
121 context: "registryUrl",
122 value: registry_url.to_string(),
123 })?
124 .join(ADMIN_BOOTSTRAP_PATH.trim_start_matches('/'))
125 .map_err(|_| CoreError::InvalidUrl {
126 context: "registryUrl",
127 value: registry_url.to_string(),
128 })?;
129 Ok(joined.to_string())
130}
131
132fn parse_error_message(response_body: &str) -> String {
133 match serde_json::from_str::<ErrorEnvelope>(response_body) {
134 Ok(envelope) => envelope
135 .error
136 .and_then(|error| error.message)
137 .unwrap_or_else(|| response_body.to_string()),
138 Err(_) => response_body.to_string(),
139 }
140}
141
142fn parse_bootstrap_payload(
143 payload: AdminBootstrapPayload,
144 registry_url: String,
145) -> Result<AdminBootstrapResult> {
146 Ok(AdminBootstrapResult {
147 human: AdminHuman {
148 id: parse_non_empty(&payload.human.id, "human.id")?,
149 did: parse_non_empty(&payload.human.did, "human.did")?,
150 display_name: parse_non_empty(&payload.human.display_name, "human.displayName")?,
151 role: parse_non_empty(&payload.human.role, "human.role")?,
152 status: parse_non_empty(&payload.human.status, "human.status")?,
153 },
154 api_key: AdminApiKey {
155 id: parse_non_empty(&payload.api_key.id, "apiKey.id")?,
156 name: parse_non_empty(&payload.api_key.name, "apiKey.name")?,
157 token: parse_non_empty(&payload.api_key.token, "apiKey.token")?,
158 },
159 internal_service: AdminInternalService {
160 id: parse_non_empty(&payload.internal_service.id, "internalService.id")?,
161 name: parse_non_empty(&payload.internal_service.name, "internalService.name")?,
162 },
163 registry_url,
164 })
165}
166
167pub fn bootstrap_admin(
169 options: &ConfigPathOptions,
170 input: AdminBootstrapInput,
171) -> Result<AdminBootstrapResult> {
172 let config = resolve_config(options)?;
173 let registry_url = normalize_registry_url(
174 input
175 .registry_url
176 .as_deref()
177 .unwrap_or(config.registry_url.as_str()),
178 )?;
179 let bootstrap_secret = parse_non_empty(&input.bootstrap_secret, "bootstrapSecret")?;
180
181 let response = blocking_client()?
182 .post(to_request_url(®istry_url)?)
183 .header("x-bootstrap-secret", bootstrap_secret)
184 .header("content-type", "application/json")
185 .json(&serde_json::json!({
186 "displayName": input
187 .display_name
188 .as_deref()
189 .map(str::trim)
190 .filter(|value| !value.is_empty()),
191 "apiKeyName": input
192 .api_key_name
193 .as_deref()
194 .map(str::trim)
195 .filter(|value| !value.is_empty()),
196 }))
197 .send()
198 .map_err(|error| CoreError::Http(error.to_string()))?;
199
200 if !response.status().is_success() {
201 let status = response.status().as_u16();
202 let response_body = response.text().unwrap_or_default();
203 return Err(CoreError::HttpStatus {
204 status,
205 message: parse_error_message(&response_body),
206 });
207 }
208
209 let payload = response
210 .json::<AdminBootstrapPayload>()
211 .map_err(|error| CoreError::Http(error.to_string()))?;
212 parse_bootstrap_payload(payload, registry_url)
213}
214
215pub fn persist_bootstrap_config(
217 options: &ConfigPathOptions,
218 bootstrap: &AdminBootstrapResult,
219) -> Result<CliConfig> {
220 let mut config = read_config(options)?;
221 config.registry_url = normalize_registry_url(&bootstrap.registry_url)?;
222 config.api_key = Some(parse_non_empty(&bootstrap.api_key.token, "apiKey.token")?);
223 if !bootstrap.human.display_name.trim().is_empty() {
224 config.human_name = Some(bootstrap.human.display_name.trim().to_string());
225 }
226 let _ = write_config(&config, options)?;
227 Ok(config)
228}
229
230#[cfg(test)]
231mod tests {
232 use std::path::Path;
233
234 use tempfile::TempDir;
235 use wiremock::matchers::{header, method, path};
236 use wiremock::{Mock, MockServer, ResponseTemplate};
237
238 use crate::config::{CliConfig, ConfigPathOptions, read_config, write_config};
239
240 use super::{AdminBootstrapInput, bootstrap_admin, persist_bootstrap_config};
241
242 fn options(home: &Path) -> ConfigPathOptions {
243 ConfigPathOptions {
244 home_dir: Some(home.to_path_buf()),
245 registry_url_hint: None,
246 }
247 }
248
249 fn seed_config(home: &Path, registry_url: &str) {
250 let options = options(home);
251 let config = CliConfig {
252 registry_url: registry_url.to_string(),
253 proxy_url: None,
254 api_key: None,
255 human_name: None,
256 };
257 let _ = write_config(&config, &options).expect("write config");
258 }
259
260 #[tokio::test]
261 async fn bootstrap_and_persist_admin_config() {
262 let server = MockServer::start().await;
263 Mock::given(method("POST"))
264 .and(path("/v1/admin/bootstrap"))
265 .and(header("x-bootstrap-secret", "secret"))
266 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
267 "human": {
268 "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
269 "did": "did:cdi:registry.clawdentity.com:human:01HF7YAT31JZHSMW1CG6Q6MHB7",
270 "displayName": "Alice",
271 "role": "admin",
272 "status": "active"
273 },
274 "apiKey": {
275 "id": "01HF7YAT00W6W7CM7N3W5FDXT5",
276 "name": "admin-cli",
277 "token": "pat_admin"
278 },
279 "internalService": {
280 "id": "01HF7YAT00W6W7CM7N3W5FDXT6",
281 "name": "bootstrap-internal"
282 }
283 })))
284 .mount(&server)
285 .await;
286
287 let temp = TempDir::new().expect("temp dir");
288 seed_config(temp.path(), &server.uri());
289 let options = options(temp.path());
290
291 let bootstrap_options = options.clone();
292 let bootstrap = tokio::task::spawn_blocking(move || {
293 bootstrap_admin(
294 &bootstrap_options,
295 AdminBootstrapInput {
296 bootstrap_secret: "secret".to_string(),
297 display_name: Some("Alice".to_string()),
298 api_key_name: Some("admin-cli".to_string()),
299 registry_url: None,
300 },
301 )
302 })
303 .await
304 .expect("join")
305 .expect("bootstrap");
306 assert_eq!(bootstrap.api_key.token, "pat_admin");
307
308 let _ = persist_bootstrap_config(&options, &bootstrap).expect("persist");
309 let config = read_config(&options).expect("read");
310 assert_eq!(config.api_key.as_deref(), Some("pat_admin"));
311 assert_eq!(config.human_name.as_deref(), Some("Alice"));
312 }
313}