Skip to content

Commit 5a1ac37

Browse files
authored
Admin API to dynamically set policy data (#4115)
2 parents 5fc97d1 + 6a37fdf commit 5a1ac37

33 files changed

+1694
-11
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=0.14.1" }
6161
version = "0.14.1"
6262
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
6363

64+
# An `Arc` that can be atomically updated
6465
[workspace.dependencies.arc-swap]
6566
version = "1.7.1"
6667

crates/cli/src/app_state.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ impl FromRef<AppState> for Limiter {
203203
}
204204
}
205205

206+
impl FromRef<AppState> for Arc<PolicyFactory> {
207+
fn from_ref(input: &AppState) -> Self {
208+
input.policy_factory.clone()
209+
}
210+
}
211+
206212
impl FromRef<AppState> for Arc<dyn HomeserverConnection> {
207213
fn from_ref(input: &AppState) -> Self {
208214
Arc::clone(&input.homeserver_connection)

crates/cli/src/commands/debug.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 New Vector Ltd.
1+
// Copyright 2024, 2025 New Vector Ltd.
22
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
33
//
44
// SPDX-License-Identifier: AGPL-3.0-only
@@ -8,10 +8,14 @@ use std::process::ExitCode;
88

99
use clap::Parser;
1010
use figment::Figment;
11-
use mas_config::{ConfigurationSection, ConfigurationSectionExt, MatrixConfig, PolicyConfig};
11+
use mas_config::{
12+
ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PolicyConfig,
13+
};
1214
use tracing::{info, info_span};
1315

14-
use crate::util::policy_factory_from_config;
16+
use crate::util::{
17+
database_pool_from_config, load_policy_factory_dynamic_data, policy_factory_from_config,
18+
};
1519

1620
#[derive(Parser, Debug)]
1721
pub(super) struct Options {
@@ -22,21 +26,31 @@ pub(super) struct Options {
2226
#[derive(Parser, Debug)]
2327
enum Subcommand {
2428
/// Check that the policies compile
25-
Policy,
29+
Policy {
30+
/// With dynamic data loaded
31+
#[arg(long)]
32+
with_dynamic_data: bool,
33+
},
2634
}
2735

2836
impl Options {
2937
#[tracing::instrument(skip_all)]
3038
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
3139
use Subcommand as SC;
3240
match self.subcommand {
33-
SC::Policy => {
41+
SC::Policy { with_dynamic_data } => {
3442
let _span = info_span!("cli.debug.policy").entered();
3543
let config = PolicyConfig::extract_or_default(figment)?;
3644
let matrix_config = MatrixConfig::extract(figment)?;
3745
info!("Loading and compiling the policy module");
3846
let policy_factory = policy_factory_from_config(&config, &matrix_config).await?;
3947

48+
if with_dynamic_data {
49+
let database_config = DatabaseConfig::extract(figment)?;
50+
let pool = database_pool_from_config(&database_config).await?;
51+
load_policy_factory_dynamic_data(&policy_factory, &pool).await?;
52+
}
53+
4054
let _instance = policy_factory.instantiate().await?;
4155
}
4256
}

crates/cli/src/commands/server.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ use crate::{
2525
app_state::AppState,
2626
lifecycle::LifecycleManager,
2727
util::{
28-
database_pool_from_config, homeserver_connection_from_config, mailer_from_config,
28+
database_pool_from_config, homeserver_connection_from_config,
29+
load_policy_factory_dynamic_data_continuously, mailer_from_config,
2930
password_manager_from_config, policy_factory_from_config, site_config_from_config,
3031
templates_from_config, test_mailer_in_background,
3132
},
@@ -129,6 +130,14 @@ impl Options {
129130
let policy_factory = policy_factory_from_config(&config.policy, &config.matrix).await?;
130131
let policy_factory = Arc::new(policy_factory);
131132

133+
load_policy_factory_dynamic_data_continuously(
134+
&policy_factory,
135+
&pool,
136+
shutdown.soft_shutdown_token(),
137+
shutdown.task_tracker(),
138+
)
139+
.await?;
140+
132141
let url_builder = UrlBuilder::new(
133142
config.http.public_base.clone(),
134143
config.http.issuer.clone(),

crates/cli/src/util.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection};
1919
use mas_matrix_synapse::SynapseConnection;
2020
use mas_policy::PolicyFactory;
2121
use mas_router::UrlBuilder;
22+
use mas_storage::RepositoryAccess;
23+
use mas_storage_pg::PgRepository;
2224
use mas_templates::{SiteConfigExt, TemplateLoadingError, Templates};
2325
use sqlx::{
2426
ConnectOptions, PgConnection, PgPool,
2527
postgres::{PgConnectOptions, PgPoolOptions},
2628
};
29+
use tokio_util::{sync::CancellationToken, task::TaskTracker};
2730
use tracing::{Instrument, log::LevelFilter};
2831

2932
pub async fn password_manager_from_config(
@@ -377,6 +380,66 @@ pub async fn database_connection_from_config_with_options(
377380
.context("could not connect to the database")
378381
}
379382

383+
/// Update the policy factory dynamic data from the database and spawn a task to
384+
/// periodically update it
385+
// XXX: this could be put somewhere else?
386+
pub async fn load_policy_factory_dynamic_data_continuously(
387+
policy_factory: &Arc<PolicyFactory>,
388+
pool: &PgPool,
389+
cancellation_token: CancellationToken,
390+
task_tracker: &TaskTracker,
391+
) -> Result<(), anyhow::Error> {
392+
let policy_factory = policy_factory.clone();
393+
let pool = pool.clone();
394+
395+
load_policy_factory_dynamic_data(&policy_factory, &pool).await?;
396+
397+
task_tracker.spawn(async move {
398+
let mut interval = tokio::time::interval(Duration::from_secs(60));
399+
400+
loop {
401+
tokio::select! {
402+
() = cancellation_token.cancelled() => {
403+
return;
404+
}
405+
_ = interval.tick() => {}
406+
}
407+
408+
if let Err(err) = load_policy_factory_dynamic_data(&policy_factory, &pool).await {
409+
tracing::error!(
410+
error = ?err,
411+
"Failed to load policy factory dynamic data"
412+
);
413+
cancellation_token.cancel();
414+
return;
415+
}
416+
}
417+
});
418+
419+
Ok(())
420+
}
421+
422+
/// Update the policy factory dynamic data from the database
423+
#[tracing::instrument(name = "policy.load_dynamic_data", skip_all, err(Debug))]
424+
pub async fn load_policy_factory_dynamic_data(
425+
policy_factory: &PolicyFactory,
426+
pool: &PgPool,
427+
) -> Result<(), anyhow::Error> {
428+
let mut repo = PgRepository::from_pool(pool)
429+
.await
430+
.context("Failed to acquire database connection")?;
431+
432+
if let Some(data) = repo.policy_data().get().await? {
433+
let id = data.id;
434+
let updated = policy_factory.set_dynamic_data(data).await?;
435+
if updated {
436+
tracing::info!(policy_data.id = %id, "Loaded dynamic policy data from the database");
437+
}
438+
}
439+
440+
Ok(())
441+
}
442+
380443
/// Create a clonable, type-erased [`HomeserverConnection`] from the
381444
/// configuration
382445
pub fn homeserver_connection_from_config(

crates/data-model/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use thiserror::Error;
1010

1111
pub(crate) mod compat;
1212
pub mod oauth2;
13+
pub(crate) mod policy_data;
1314
mod site_config;
1415
pub(crate) mod tokens;
1516
pub(crate) mod upstream_oauth2;
@@ -32,6 +33,7 @@ pub use self::{
3233
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant,
3334
DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState,
3435
},
36+
policy_data::PolicyData,
3537
site_config::{CaptchaConfig, CaptchaService, SessionExpirationConfig, SiteConfig},
3638
tokens::{
3739
AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use chrono::{DateTime, Utc};
7+
use serde::Serialize;
8+
use ulid::Ulid;
9+
10+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
11+
pub struct PolicyData {
12+
pub id: Ulid,
13+
pub created_at: DateTime<Utc>,
14+
pub data: serde_json::Value,
15+
}

crates/handlers/src/admin/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use indexmap::IndexMap;
2222
use mas_axum_utils::FancyError;
2323
use mas_http::CorsLayerExt;
2424
use mas_matrix::HomeserverConnection;
25+
use mas_policy::PolicyFactory;
2526
use mas_router::{
2627
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
2728
UrlBuilder,
@@ -47,6 +48,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi {
4748
description: Some("Manage compatibility sessions from legacy clients".to_owned()),
4849
..Tag::default()
4950
})
51+
.tag(Tag {
52+
name: "policy-data".to_owned(),
53+
description: Some("Manage the dynamic policy data".to_owned()),
54+
..Tag::default()
55+
})
5056
.tag(Tag {
5157
name: "oauth2-session".to_owned(),
5258
description: Some("Manage OAuth2 sessions".to_owned()),
@@ -115,6 +121,7 @@ where
115121
CallContext: FromRequestParts<S>,
116122
Templates: FromRef<S>,
117123
UrlBuilder: FromRef<S>,
124+
Arc<PolicyFactory>: FromRef<S>,
118125
{
119126
// We *always* want to explicitly set the possible responses, beacuse the
120127
// infered ones are not necessarily correct

crates/handlers/src/admin/model.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,3 +534,50 @@ impl UpstreamOAuthLink {
534534
]
535535
}
536536
}
537+
538+
/// The policy data
539+
#[derive(Serialize, JsonSchema)]
540+
pub struct PolicyData {
541+
#[serde(skip)]
542+
id: Ulid,
543+
544+
/// The creation date of the policy data
545+
created_at: DateTime<Utc>,
546+
547+
/// The policy data content
548+
data: serde_json::Value,
549+
}
550+
551+
impl From<mas_data_model::PolicyData> for PolicyData {
552+
fn from(policy_data: mas_data_model::PolicyData) -> Self {
553+
Self {
554+
id: policy_data.id,
555+
created_at: policy_data.created_at,
556+
data: policy_data.data,
557+
}
558+
}
559+
}
560+
561+
impl Resource for PolicyData {
562+
const KIND: &'static str = "policy-data";
563+
const PATH: &'static str = "/api/admin/v1/policy-data";
564+
565+
fn id(&self) -> Ulid {
566+
self.id
567+
}
568+
}
569+
570+
impl PolicyData {
571+
/// Samples of policy data
572+
pub fn samples() -> [Self; 1] {
573+
[Self {
574+
id: Ulid::from_bytes([0x01; 16]),
575+
created_at: DateTime::default(),
576+
data: serde_json::json!({
577+
"hello": "world",
578+
"foo": 42,
579+
"bar": true
580+
}),
581+
}]
582+
}
583+
}

0 commit comments

Comments
 (0)