-
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Copyright 2025 New Vector Ltd. | ||
| // | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| // Please see LICENSE in the repository root for full details. | ||
|
|
||
| use chrono::{DateTime, Utc}; | ||
| use serde::Serialize; | ||
| use ulid::Ulid; | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | ||
| pub struct PolicyData { | ||
| pub id: Ulid, | ||
| pub created_at: DateTime<Utc>, | ||
| pub data: serde_json::Value, | ||
| } |
14 changes: 14 additions & 0 deletions
14
...rage-pg/.sqlx/query-5006c3e60c98c91a0b0fbb3205373e81d9b75e90929af80961f8b5910873a43e.json
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
32 changes: 32 additions & 0 deletions
32
...rage-pg/.sqlx/query-9fe87eeaf4b7d0ba09b59ddad3476eb57ccb6e4053ab8f4450dd4a9d1f6ba108.json
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
16 changes: 16 additions & 0 deletions
16
...rage-pg/.sqlx/query-b6c4f4a23968cba2a82c2b7cfffc05a7ed582c9e5c1f65d27b0686f843ccfe42.json
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
11 changes: 11 additions & 0 deletions
11
crates/storage-pg/migrations/20250225091000_dynamic_policy_data.sql
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,11 @@ | ||
| -- Copyright 2025 New Vector Ltd. | ||
| -- | ||
| -- SPDX-License-Identifier: AGPL-3.0-only | ||
| -- Please see LICENSE in the repository root for full details. | ||
|
|
||
| -- Add a table which stores the latest policy data | ||
| CREATE TABLE IF NOT EXISTS policy_data ( | ||
| policy_data_id UUID PRIMARY KEY, | ||
| created_at TIMESTAMP WITH TIME ZONE NOT NULL, | ||
| data JSONB NOT NULL | ||
| ); | ||
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,204 @@ | ||
| // Copyright 2025 New Vector Ltd. | ||
| // | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| // Please see LICENSE in the repository root for full details. | ||
|
|
||
| //! A module containing the PostgreSQL implementation of the policy data | ||
| //! storage. | ||
|
|
||
| use async_trait::async_trait; | ||
| use mas_data_model::PolicyData; | ||
| use mas_storage::{Clock, policy_data::PolicyDataRepository}; | ||
| use rand::RngCore; | ||
| use serde_json::Value; | ||
| use sqlx::{PgConnection, types::Json}; | ||
| use ulid::Ulid; | ||
| use uuid::Uuid; | ||
|
|
||
| use crate::{DatabaseError, ExecuteExt}; | ||
|
|
||
| /// An implementation of [`PolicyDataRepository`] for a PostgreSQL connection. | ||
| pub struct PgPolicyDataRepository<'c> { | ||
| conn: &'c mut PgConnection, | ||
| } | ||
|
|
||
| impl<'c> PgPolicyDataRepository<'c> { | ||
| /// Create a new [`PgPolicyDataRepository`] from an active PostgreSQL | ||
| /// connection. | ||
| #[must_use] | ||
| pub fn new(conn: &'c mut PgConnection) -> Self { | ||
| Self { conn } | ||
| } | ||
| } | ||
|
|
||
| struct PolicyDataLookup { | ||
| policy_data_id: Uuid, | ||
| created_at: chrono::DateTime<chrono::Utc>, | ||
| data: Json<Value>, | ||
| } | ||
|
|
||
| impl From<PolicyDataLookup> for PolicyData { | ||
| fn from(value: PolicyDataLookup) -> Self { | ||
| PolicyData { | ||
| id: value.policy_data_id.into(), | ||
| created_at: value.created_at, | ||
| data: value.data.0, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl PolicyDataRepository for PgPolicyDataRepository<'_> { | ||
| type Error = DatabaseError; | ||
|
|
||
| #[tracing::instrument( | ||
| name = "db.policy_data.get", | ||
| skip_all, | ||
| fields( | ||
| db.query.text, | ||
| ), | ||
| err, | ||
| )] | ||
| async fn get(&mut self) -> Result<Option<PolicyData>, Self::Error> { | ||
| let row = sqlx::query_as!( | ||
| PolicyDataLookup, | ||
| r#" | ||
| SELECT policy_data_id, created_at, data | ||
| FROM policy_data | ||
| ORDER BY policy_data_id DESC | ||
| LIMIT 1 | ||
| "# | ||
| ) | ||
| .traced() | ||
| .fetch_optional(&mut *self.conn) | ||
| .await?; | ||
|
|
||
| let Some(row) = row else { | ||
| return Ok(None); | ||
| }; | ||
|
|
||
| Ok(Some(row.into())) | ||
| } | ||
|
|
||
| #[tracing::instrument( | ||
| name = "db.policy_data.set", | ||
| skip_all, | ||
| fields( | ||
| db.query.text, | ||
| ), | ||
| err, | ||
| )] | ||
| async fn set( | ||
| &mut self, | ||
| rng: &mut (dyn RngCore + Send), | ||
| clock: &dyn Clock, | ||
| data: Value, | ||
| ) -> Result<PolicyData, Self::Error> { | ||
| let created_at = clock.now(); | ||
| let id = Ulid::from_datetime_with_source(created_at.into(), rng); | ||
|
|
||
| sqlx::query!( | ||
| r#" | ||
| INSERT INTO policy_data (policy_data_id, created_at, data) | ||
| VALUES ($1, $2, $3) | ||
| "#, | ||
| Uuid::from(id), | ||
| created_at, | ||
| data, | ||
| ) | ||
| .traced() | ||
| .execute(&mut *self.conn) | ||
| .await?; | ||
|
|
||
| Ok(PolicyData { | ||
| id, | ||
| created_at, | ||
| data, | ||
| }) | ||
| } | ||
|
|
||
| #[tracing::instrument( | ||
| name = "db.policy_data.prune", | ||
| skip_all, | ||
| fields( | ||
| db.query.text, | ||
| ), | ||
| err, | ||
| )] | ||
| async fn prune(&mut self, keep: usize) -> Result<usize, Self::Error> { | ||
| let res = sqlx::query!( | ||
| r#" | ||
| DELETE FROM policy_data | ||
| WHERE policy_data_id IN ( | ||
| SELECT policy_data_id | ||
| FROM policy_data | ||
| ORDER BY policy_data_id DESC | ||
| OFFSET $1 | ||
| ) | ||
| "#, | ||
| i64::try_from(keep).map_err(DatabaseError::to_invalid_operation)? | ||
| ) | ||
| .traced() | ||
| .execute(&mut *self.conn) | ||
| .await?; | ||
|
|
||
| Ok(res | ||
| .rows_affected() | ||
| .try_into() | ||
| .map_err(DatabaseError::to_invalid_operation)?) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use mas_storage::{clock::MockClock, policy_data::PolicyDataRepository}; | ||
| use rand::SeedableRng; | ||
| use rand_chacha::ChaChaRng; | ||
| use serde_json::json; | ||
| use sqlx::PgPool; | ||
|
|
||
| use crate::policy_data::PgPolicyDataRepository; | ||
|
|
||
| #[sqlx::test(migrator = "crate::MIGRATOR")] | ||
| async fn test_policy_data(pool: PgPool) { | ||
| let mut rng = ChaChaRng::seed_from_u64(42); | ||
| let clock = MockClock::default(); | ||
| let mut conn = pool.acquire().await.unwrap(); | ||
| let mut repo = PgPolicyDataRepository::new(&mut conn); | ||
|
|
||
| // Get an empty state at first | ||
| let data = repo.get().await.unwrap(); | ||
| assert_eq!(data, None); | ||
|
|
||
| // Set some data | ||
| let value1 = json!({"hello": "world"}); | ||
| let policy_data1 = repo.set(&mut rng, &clock, value1.clone()).await.unwrap(); | ||
| assert_eq!(policy_data1.data, value1); | ||
|
|
||
| let data_fetched1 = repo.get().await.unwrap().unwrap(); | ||
| assert_eq!(policy_data1, data_fetched1); | ||
|
|
||
| // Set some new data | ||
| clock.advance(chrono::Duration::seconds(1)); | ||
| let value2 = json!({"foo": "bar"}); | ||
| let policy_data2 = repo.set(&mut rng, &clock, value2.clone()).await.unwrap(); | ||
| assert_eq!(policy_data2.data, value2); | ||
|
|
||
| // Check the new data is fetched | ||
| let data_fetched2 = repo.get().await.unwrap().unwrap(); | ||
| assert_eq!(data_fetched2, policy_data2); | ||
|
|
||
| // Prune until the first entry | ||
| let affected = repo.prune(1).await.unwrap(); | ||
| let data_fetched3 = repo.get().await.unwrap().unwrap(); | ||
| assert_eq!(data_fetched3, policy_data2); | ||
| assert_eq!(affected, 1); | ||
|
|
||
| // Do a raw query to check the other rows were pruned | ||
| let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM policy_data") | ||
| .fetch_one(&mut *conn) | ||
| .await | ||
| .unwrap(); | ||
| assert_eq!(count, 1); | ||
| } | ||
| } |
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
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.