-
Notifications
You must be signed in to change notification settings - Fork 67
Admin API to dynamically set policy data #4115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
aa3af15
storage: store dynamic policy data in the database
sandhose d393494
Admin API to get and set policy data
sandhose 7569223
policy: allow dynamically setting policy data
sandhose c3296a2
Make the admin API update the local policy data
sandhose c8a33f0
Regularly load the latest dynamic policy data from the database
sandhose b663c4f
Merge branch 'main' into quenting/dynamic-policy-data
sandhose 5a97ec5
Merge branch 'main' into quenting/dynamic-policy-data
sandhose 9b25472
Merge remote-tracking branch 'origin/main' into quenting/dynamic-poli…
sandhose 97d2b75
Add a comment on the migration stating that we keep an history of the…
sandhose 8581ca1
Prune stale policy data once a day
sandhose 6a37fdf
Merge branch 'main' into quenting/dynamic-policy-data
sandhose File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| ] | ||
| } | ||
| "###); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| ] | ||
| } | ||
| "###); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.