From aec5d80dbdebe6e13c376b0d97d2e51d2d8fe46e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 25 Feb 2025 13:06:44 +0100 Subject: [PATCH] Admin API to get and set policy data --- crates/handlers/src/admin/mod.rs | 5 + crates/handlers/src/admin/model.rs | 47 ++++ crates/handlers/src/admin/v1/mod.rs | 16 ++ .../handlers/src/admin/v1/policy_data/get.rs | 153 ++++++++++ .../src/admin/v1/policy_data/get_latest.rs | 149 ++++++++++ .../handlers/src/admin/v1/policy_data/mod.rs | 14 + .../handlers/src/admin/v1/policy_data/set.rs | 133 +++++++++ docs/api/spec.json | 263 ++++++++++++++++++ 8 files changed, 780 insertions(+) create mode 100644 crates/handlers/src/admin/v1/policy_data/get.rs create mode 100644 crates/handlers/src/admin/v1/policy_data/get_latest.rs create mode 100644 crates/handlers/src/admin/v1/policy_data/mod.rs create mode 100644 crates/handlers/src/admin/v1/policy_data/set.rs diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 4edc8b2ca..4e65ba742 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -45,6 +45,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { description: Some("Manage compatibility sessions from legacy clients".to_owned()), ..Tag::default() }) + .tag(Tag { + name: "policy-data".to_owned(), + description: Some("Manage the dynamic policy data".to_owned()), + ..Tag::default() + }) .tag(Tag { name: "oauth2-session".to_owned(), description: Some("Manage OAuth2 sessions".to_owned()), diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index b98770379..ec4f8cbc2 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -534,3 +534,50 @@ impl UpstreamOAuthLink { ] } } + +/// The policy data +#[derive(Serialize, JsonSchema)] +pub struct PolicyData { + #[serde(skip)] + id: Ulid, + + /// The creation date of the policy data + created_at: DateTime, + + /// The policy data content + data: serde_json::Value, +} + +impl From for PolicyData { + fn from(policy_data: mas_data_model::PolicyData) -> Self { + Self { + id: policy_data.id, + created_at: policy_data.created_at, + data: policy_data.data, + } + } +} + +impl Resource for PolicyData { + const KIND: &'static str = "policy-data"; + const PATH: &'static str = "/api/admin/v1/policy-data"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl PolicyData { + /// Samples of policy data + pub fn samples() -> [Self; 1] { + [Self { + id: Ulid::from_bytes([0x01; 16]), + created_at: DateTime::default(), + data: serde_json::json!({ + "hello": "world", + "foo": 42, + "bar": true + }), + }] + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 02c63e2a5..ae258c842 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -17,6 +17,7 @@ use crate::passwords::PasswordManager; mod compat_sessions; mod oauth2_sessions; +mod policy_data; mod upstream_oauth_links; mod user_emails; mod user_sessions; @@ -47,6 +48,21 @@ where "/oauth2-sessions/{id}", get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc), ) + .api_route( + "/policy-data", + post_with(self::policy_data::set, self::policy_data::set_doc), + ) + .api_route( + "/policy-data/latest", + get_with( + self::policy_data::get_latest, + self::policy_data::get_latest_doc, + ), + ) + .api_route( + "/policy-data/{id}", + get_with(self::policy_data::get, self::policy_data::get_doc), + ) .api_route( "/users", get_with(self::users::list, self::users::list_doc) diff --git a/crates/handlers/src/admin/v1/policy_data/get.rs b/crates/handlers/src/admin/v1/policy_data/get.rs new file mode 100644 index 000000000..338c999b3 --- /dev/null +++ b/crates/handlers/src/admin/v1/policy_data/get.rs @@ -0,0 +1,153 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::PolicyData, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Policy data with ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getPolicyData") + .summary("Get policy data by ID") + .tag("policy-data") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PolicyData::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Policy data was found").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Policy data was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.policy_data.get", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let policy_data = repo + .policy_data() + .get() + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical(policy_data.into()))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let policy_data = repo + .policy_data() + .set( + &mut rng, + &state.clock, + serde_json::json!({"hello": "world"}), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(format!("/api/admin/v1/policy-data/{}", policy_data.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "policy-data", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "data": { + "hello": "world" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::get(format!("/api/admin/v1/policy-data/{}", Ulid::nil())) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "Policy data with ID 00000000000000000000000000 not found" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/policy_data/get_latest.rs b/crates/handlers/src/admin/v1/policy_data/get_latest.rs new file mode 100644 index 000000000..7b4c0654f --- /dev/null +++ b/crates/handlers/src/admin/v1/policy_data/get_latest.rs @@ -0,0 +1,149 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; + +use crate::{ + admin::{ + call_context::CallContext, + model::PolicyData, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("No policy data found")] + NotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getLatestPolicyData") + .summary("Get the latest policy data") + .tag("policy-data") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PolicyData::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Latest policy data was found") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound); + t.description("No policy data was found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.policy_data.get_latest", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, +) -> Result>, RouteError> { + let policy_data = repo + .policy_data() + .get() + .await? + .ok_or(RouteError::NotFound)?; + + Ok(Json(SingleResponse::new_canonical(policy_data.into()))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_latest(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + repo.policy_data() + .set( + &mut rng, + &state.clock, + serde_json::json!({"hello": "world"}), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get("/api/admin/v1/policy-data/latest") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "policy-data", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "data": { + "hello": "world" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_no_latest(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::get("/api/admin/v1/policy-data/latest") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "No policy data found" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/policy_data/mod.rs b/crates/handlers/src/admin/v1/policy_data/mod.rs new file mode 100644 index 000000000..9143a2e11 --- /dev/null +++ b/crates/handlers/src/admin/v1/policy_data/mod.rs @@ -0,0 +1,14 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod get; +mod get_latest; +mod set; + +pub use self::{ + get::{doc as get_doc, handler as get}, + get_latest::{doc as get_latest_doc, handler as get_latest}, + set::{doc as set_doc, handler as set}, +}; diff --git a/crates/handlers/src/admin/v1/policy_data/set.rs b/crates/handlers/src/admin/v1/policy_data/set.rs new file mode 100644 index 000000000..aa3edfe5a --- /dev/null +++ b/crates/handlers/src/admin/v1/policy_data/set.rs @@ -0,0 +1,133 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_storage::BoxRng; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::PolicyData, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = StatusCode::INTERNAL_SERVER_ERROR; + (status, Json(error)).into_response() + } +} + +fn data_example() -> serde_json::Value { + serde_json::json!({ + "hello": "world", + "foo": 42, + "bar": true + }) +} + +/// # JSON payload for the `POST /api/admin/v1/policy-data` +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "SetPolicyDataRequest")] +pub struct SetPolicyDataRequest { + #[schemars(example = "data_example")] + pub data: serde_json::Value, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("setPolicyData") + .summary("Set the current policy data") + .tag("policy-data") + .response_with::<201, Json>, _>(|t| { + let [sample, ..] = PolicyData::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Policy data was successfully set") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.policy_data.set", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(request): Json, +) -> Result<(StatusCode, Json>), RouteError> { + let policy_data = repo + .policy_data() + .set(&mut rng, &clock, request.data) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical(policy_data.into())), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/policy-data") + .bearer(&token) + .json(serde_json::json!({ + "data": { + "hello": "world" + } + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "policy-data", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "data": { + "hello": "world" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } +} diff --git a/docs/api/spec.json b/docs/api/spec.json index ec593ba9e..1fcc17116 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -593,6 +593,185 @@ } } }, + "/api/admin/v1/policy-data": { + "post": { + "tags": [ + "policy-data" + ], + "summary": "Set the current policy data", + "operationId": "setPolicyData", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPolicyDataRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Policy data was successfully set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" + }, + "example": { + "data": { + "type": "policy-data", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "data": { + "hello": "world", + "foo": 42, + "bar": true + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + } + } + } + } + } + } + }, + "/api/admin/v1/policy-data/latest": { + "get": { + "tags": [ + "policy-data" + ], + "summary": "Get the latest policy data", + "operationId": "getLatestPolicyData", + "responses": { + "200": { + "description": "Latest policy data was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" + }, + "example": { + "data": { + "type": "policy-data", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "data": { + "hello": "world", + "foo": 42, + "bar": true + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "No policy data was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "No policy data found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/policy-data/{id}": { + "get": { + "tags": [ + "policy-data" + ], + "summary": "Get policy data by ID", + "operationId": "getPolicyData", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Policy data was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" + }, + "example": { + "data": { + "type": "policy-data", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "data": { + "hello": "world", + "foo": 42, + "bar": true + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Policy data was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Policy data with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users": { "get": { "tags": [ @@ -2666,6 +2845,86 @@ } } }, + "SetPolicyDataRequest": { + "title": "JSON payload for the `POST /api/admin/v1/policy-data`", + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "examples": [ + { + "hello": "world", + "foo": 42, + "bar": true + } + ] + } + } + }, + "SingleResponse_for_PolicyData": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_PolicyData" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "SingleResource_for_PolicyData": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/PolicyData" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "PolicyData": { + "description": "The policy data", + "type": "object", + "required": [ + "created_at", + "data" + ], + "properties": { + "created_at": { + "description": "The creation date of the policy data", + "type": "string", + "format": "date-time" + }, + "data": { + "description": "The policy data content" + } + } + }, "UserFilter": { "type": "object", "properties": { @@ -3252,6 +3511,10 @@ "name": "compat-session", "description": "Manage compatibility sessions from legacy clients" }, + { + "name": "policy-data", + "description": "Manage the dynamic policy data" + }, { "name": "oauth2-session", "description": "Manage OAuth2 sessions"