feat: add permission control

This commit is contained in:
2025-02-22 20:26:14 +08:00
parent ae40a3a7f8
commit c2f74dc369
33 changed files with 707 additions and 226 deletions

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use async_graphql::{
ServerResult, Value,
extensions::{Extension, ExtensionContext, ExtensionFactory, NextResolve, ResolveInfo},
};
pub struct GraphqlAuthExtension;
#[async_trait::async_trait]
impl Extension for GraphqlAuthExtension {
async fn resolve(
&self,
ctx: &ExtensionContext<'_>,
info: ResolveInfo<'_>,
next: NextResolve<'_>,
) -> ServerResult<Option<Value>> {
dbg!(info.field);
next.run(ctx, info).await
}
}
impl ExtensionFactory for GraphqlAuthExtension {
fn create(&self) -> Arc<dyn Extension> {
Arc::new(GraphqlAuthExtension)
}
}

View File

@@ -0,0 +1,199 @@
use std::sync::Arc;
use async_graphql::dynamic::{ResolverContext, ValueAccessor};
use sea_orm::EntityTrait;
use seaography::{BuilderContext, FnGuard, GuardAction};
use super::util::get_entity_key;
use crate::{
auth::{AuthError, AuthUserInfo},
graphql::util::get_column_key,
};
fn guard_data_object_accessor_with_subscriber_id(
value: ValueAccessor<'_>,
column_name: &str,
subscriber_id: i32,
) -> async_graphql::Result<()> {
let obj = value.object()?;
let subscriber_id_value = obj.try_get(column_name)?;
let id = subscriber_id_value.i64()?;
if id == subscriber_id as i64 {
Ok(())
} else {
Err(async_graphql::Error::new("subscriber permission denied"))
}
}
fn guard_data_object_accessor_with_optional_subscriber_id(
value: ValueAccessor<'_>,
column_name: &str,
subscriber_id: i32,
) -> async_graphql::Result<()> {
if value.is_null() {
return Ok(());
}
let obj = value.object()?;
if let Some(subscriber_id_value) = obj.get(column_name) {
let id = subscriber_id_value.i64()?;
if id == subscriber_id as i64 {
Ok(())
} else {
Err(async_graphql::Error::new("subscriber permission denied"))
}
} else {
Ok(())
}
}
fn guard_filter_object_accessor_with_subscriber_id(
value: ValueAccessor<'_>,
column_name: &str,
subscriber_id: i32,
) -> async_graphql::Result<()> {
let obj = value.object()?;
let subscriber_id_filter_input_value = obj.try_get(column_name)?;
let subscriber_id_filter_input_obj = subscriber_id_filter_input_value.object()?;
let subscriber_id_value = subscriber_id_filter_input_obj.try_get("eq")?;
let id = subscriber_id_value.i64()?;
if id == subscriber_id as i64 {
Ok(())
} else {
Err(async_graphql::Error::new("subscriber permission denied"))
}
}
pub fn guard_entity_with_subscriber_id<T>(context: &BuilderContext, column: &T::Column) -> FnGuard
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_key = get_entity_key::<T>(context);
let entity_name = context.entity_query_field.type_name.as_ref()(&entity_key);
let column_key = get_column_key::<T>(context, column);
let column_name = Arc::new(context.entity_object.column_name.as_ref()(
&entity_key,
&column_key,
));
let entity_create_one_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name, context.entity_create_one_mutation.mutation_suffix
));
let entity_create_one_mutation_data_field_name =
Arc::new(context.entity_create_one_mutation.data_field.clone());
let entity_create_batch_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_create_batch_mutation.mutation_suffix.clone()
));
let entity_create_batch_mutation_data_field_name =
Arc::new(context.entity_create_batch_mutation.data_field.clone());
let entity_delete_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_delete_mutation.mutation_suffix.clone()
));
let entity_delete_filter_field_name =
Arc::new(context.entity_delete_mutation.filter_field.clone());
let entity_update_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name, context.entity_update_mutation.mutation_suffix
));
let entity_update_mutation_filter_field_name =
Arc::new(context.entity_update_mutation.filter_field.clone());
let entity_update_mutation_data_field_name =
Arc::new(context.entity_update_mutation.data_field.clone());
let entity_query_field_name = Arc::new(entity_name);
let entity_query_filter_field_name = Arc::new(context.entity_query_field.filters.clone());
Box::new(move |context: &ResolverContext| -> GuardAction {
match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id;
let validation_result = match context.field().name() {
field if field == entity_create_one_mutation_field_name.as_str() => context
.args
.try_get(&entity_create_one_mutation_data_field_name)
.and_then(|data_value| {
guard_data_object_accessor_with_subscriber_id(
data_value,
&column_name,
subscriber_id,
)
}),
field if field == entity_create_batch_mutation_field_name.as_str() => context
.args
.try_get(&entity_create_batch_mutation_data_field_name)
.and_then(|data_value| {
data_value.list().and_then(|data_list| {
data_list.iter().try_for_each(|data_item_value| {
guard_data_object_accessor_with_subscriber_id(
data_item_value,
&column_name,
subscriber_id,
)
})
})
}),
field if field == entity_delete_mutation_field_name.as_str() => context
.args
.try_get(&entity_delete_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
}),
field if field == entity_update_mutation_field_name.as_str() => context
.args
.try_get(&entity_update_mutation_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
})
.and_then(|_| {
match context.args.get(&entity_update_mutation_data_field_name) {
Some(data_value) => {
guard_data_object_accessor_with_optional_subscriber_id(
data_value,
&column_name,
subscriber_id,
)
}
None => Ok(()),
}
}),
field if field == entity_query_field_name.as_str() => context
.args
.try_get(&entity_query_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
}),
field => Err(async_graphql::Error::new(format!(
"unsupport graphql field {}",
field
))),
};
match validation_result.map_err(AuthError::GraphQLPermissionError) {
Ok(_) => GuardAction::Allow,
Err(err) => GuardAction::Block(Some(err.to_string())),
}
}
Err(err) => GuardAction::Block(Some(err.message)),
}
})
}

View File

@@ -1,5 +1,8 @@
pub mod config;
pub mod query_root;
pub mod extention;
pub mod guard;
pub mod schema_root;
pub mod service;
pub mod util;
pub use query_root::schema;
pub use schema_root::schema;

View File

@@ -1,56 +0,0 @@
use async_graphql::dynamic::*;
use sea_orm::DatabaseConnection;
use seaography::{Builder, BuilderContext};
lazy_static::lazy_static! { static ref CONTEXT : BuilderContext = {
BuilderContext {
..Default::default()
}
}; }
pub fn schema(
database: DatabaseConnection,
depth: Option<usize>,
complexity: Option<usize>,
) -> Result<Schema, SchemaError> {
use crate::models::*;
let mut builder = Builder::new(&CONTEXT, database.clone());
seaography::register_entities!(
builder,
[
bangumi,
downloaders,
downloads,
episodes,
subscribers,
subscription_bangumi,
subscription_episode,
subscriptions
]
);
{
builder.register_enumeration::<downloads::DownloadStatus>();
builder.register_enumeration::<subscriptions::SubscriptionCategory>();
builder.register_enumeration::<downloaders::DownloaderCategory>();
builder.register_enumeration::<downloads::DownloadMime>();
}
let schema = builder.schema_builder();
let schema = if let Some(depth) = depth {
schema.limit_depth(depth)
} else {
schema
};
let schema = if let Some(complexity) = complexity {
schema.limit_complexity(complexity)
} else {
schema
};
schema
.data(database)
.finish()
.inspect_err(|e| tracing::error!(e = ?e))
}

View File

@@ -0,0 +1,146 @@
use async_graphql::dynamic::*;
use once_cell::sync::OnceCell;
use sea_orm::{DatabaseConnection, EntityTrait, Iterable};
use seaography::{Builder, BuilderContext, FilterType, FnGuard};
use super::util::{get_entity_column_key, get_entity_key};
use crate::graphql::guard::guard_entity_with_subscriber_id;
static CONTEXT: OnceCell<BuilderContext> = OnceCell::new();
fn restrict_filter_input_for_entity<T>(
context: &mut BuilderContext,
column: &T::Column,
filter_type: Option<FilterType>,
) where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let key = get_entity_column_key::<T>(context, column);
context.filter_types.overwrites.insert(key, filter_type);
}
fn restrict_subscriber_for_entity<T>(
context: &mut BuilderContext,
column: &T::Column,
entity_guard: impl FnOnce(&BuilderContext, &T::Column) -> FnGuard,
) where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_key = get_entity_key::<T>(context);
context
.guards
.entity_guards
.insert(entity_key, entity_guard(context, column));
}
pub fn schema(
database: DatabaseConnection,
depth: Option<usize>,
complexity: Option<usize>,
) -> Result<Schema, SchemaError> {
use crate::models::*;
let context = CONTEXT.get_or_init(|| {
let mut context = BuilderContext::default();
restrict_subscriber_for_entity::<bangumi::Entity>(
&mut context,
&bangumi::Column::SubscriberId,
guard_entity_with_subscriber_id::<bangumi::Entity>,
);
restrict_subscriber_for_entity::<downloaders::Entity>(
&mut context,
&downloaders::Column::SubscriberId,
guard_entity_with_subscriber_id::<downloaders::Entity>,
);
restrict_subscriber_for_entity::<downloads::Entity>(
&mut context,
&downloads::Column::SubscriberId,
guard_entity_with_subscriber_id::<downloads::Entity>,
);
restrict_subscriber_for_entity::<episodes::Entity>(
&mut context,
&episodes::Column::SubscriberId,
guard_entity_with_subscriber_id::<episodes::Entity>,
);
restrict_subscriber_for_entity::<subscriptions::Entity>(
&mut context,
&subscriptions::Column::SubscriberId,
guard_entity_with_subscriber_id::<subscriptions::Entity>,
);
restrict_subscriber_for_entity::<subscribers::Entity>(
&mut context,
&subscribers::Column::Id,
guard_entity_with_subscriber_id::<subscribers::Entity>,
);
restrict_subscriber_for_entity::<subscription_bangumi::Entity>(
&mut context,
&subscription_bangumi::Column::SubscriberId,
guard_entity_with_subscriber_id::<subscription_bangumi::Entity>,
);
restrict_subscriber_for_entity::<subscription_episode::Entity>(
&mut context,
&subscription_episode::Column::SubscriberId,
guard_entity_with_subscriber_id::<subscription_episode::Entity>,
);
for column in subscribers::Column::iter() {
if !matches!(column, subscribers::Column::Id) {
restrict_filter_input_for_entity::<subscribers::Entity>(
&mut context,
&column,
None,
);
}
}
context
});
let mut builder = Builder::new(context, database.clone());
{
builder.register_entity::<subscribers::Entity>(
<subscribers::RelatedEntity as sea_orm::Iterable>::iter()
.map(|rel| seaography::RelationBuilder::get_relation(&rel, builder.context))
.collect(),
);
builder = builder.register_entity_dataloader_one_to_one(subscribers::Entity, tokio::spawn);
builder = builder.register_entity_dataloader_one_to_many(subscribers::Entity, tokio::spawn);
}
seaography::register_entities!(
builder,
[
bangumi,
downloaders,
downloads,
episodes,
subscription_bangumi,
subscription_episode,
subscriptions
]
);
{
builder.register_enumeration::<downloads::DownloadStatus>();
builder.register_enumeration::<subscriptions::SubscriptionCategory>();
builder.register_enumeration::<downloaders::DownloaderCategory>();
builder.register_enumeration::<downloads::DownloadMime>();
}
let schema = builder.schema_builder();
let schema = if let Some(depth) = depth {
schema.limit_depth(depth)
} else {
schema
};
let schema = if let Some(complexity) = complexity {
schema.limit_complexity(complexity)
} else {
schema
};
schema
.data(database)
// .extension(GraphqlAuthExtension)
.finish()
.inspect_err(|e| tracing::error!(e = ?e))
}

View File

@@ -4,7 +4,7 @@ use loco_rs::app::{AppContext, Initializer};
use once_cell::sync::OnceCell;
use sea_orm::DatabaseConnection;
use super::{config::AppGraphQLConfig, query_root};
use super::{config::AppGraphQLConfig, schema_root};
use crate::config::AppConfigExt;
static APP_GRAPHQL_SERVICE: OnceCell<AppGraphQLService> = OnceCell::new();
@@ -16,7 +16,7 @@ pub struct AppGraphQLService {
impl AppGraphQLService {
pub fn new(config: AppGraphQLConfig, db: DatabaseConnection) -> Result<Self, SchemaError> {
let schema = query_root::schema(db, config.depth_limit, config.complexity_limit)?;
let schema = schema_root::schema(db, config.depth_limit, config.complexity_limit)?;
Ok(Self { schema })
}

View File

@@ -0,0 +1,30 @@
use sea_orm::{EntityName, EntityTrait, IdenStatic};
use seaography::BuilderContext;
pub fn get_entity_key<T>(context: &BuilderContext) -> String
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
context.entity_object.type_name.as_ref()(<T as EntityName>::table_name(&T::default()))
}
pub fn get_column_key<T>(context: &BuilderContext, column: &T::Column) -> String
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_name = get_entity_key::<T>(context);
context.entity_object.column_name.as_ref()(&entity_name, column.as_str())
}
pub fn get_entity_column_key<T>(context: &BuilderContext, column: &T::Column) -> String
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_name = get_entity_key::<T>(context);
let column_name = get_column_key::<T>(context, column);
format!("{}.{}", &entity_name, &column_name)
}