Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
47 changes: 47 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>,

/// The policy data content
data: serde_json::Value,
}

impl From<mas_data_model::PolicyData> 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
}),
}]
}
}
16 changes: 16 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
153 changes: 153 additions & 0 deletions crates/handlers/src/admin/v1/policy_data/get.rs
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[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<SingleResponse<PolicyData>>, _>(|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<Json<SingleResponse<PolicyData>>, 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"
}
]
}
"###);
}
}
149 changes: 149 additions & 0 deletions crates/handlers/src/admin/v1/policy_data/get_latest.rs
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[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<SingleResponse<PolicyData>>, _>(|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<Json<SingleResponse<PolicyData>>, 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"
}
]
}
"###);
}
}
Loading