[WIP] Handle per user setup
All checks were successful
Test API before pr merge / test-lint (pull_request) Successful in 10s
Test before pr merge / test-translation-lint (pull_request) Successful in 39s
Test before pr merge / test-lint (pull_request) Successful in 41s
Test before pr merge / test-before-merge (pull_request) Successful in 1m39s

This commit is contained in:
Sven Heidemann 2025-04-21 18:12:21 +02:00
parent 0ed3cb846d
commit e378393813
19 changed files with 214 additions and 134 deletions

View File

@ -1,7 +1,6 @@
from abc import abstractmethod
from typing import Type, Union
from api_graphql.abc.input_abc import InputABC
from api_graphql.abc.query_abc import QueryABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from core.database.abc.data_access_object_abc import DataAccessObjectABC
@ -22,6 +21,7 @@ class MutationABC(QueryABC):
name: str,
mutation_name: str,
require_any_permission=None,
require_any=None,
public: bool = False,
):
"""
@ -29,24 +29,30 @@ class MutationABC(QueryABC):
:param str name: GraphQL mutation name
:param str mutation_name: Internal (class) mutation name without "Mutation" suffix
:param list[Permissions] require_any_permission: List of permissions required to access the field
:param tuple[list[Permissions], list[callable]] require_any: List of permissions and resolvers required to access the field
:param bool public: Define if the field can resolve without authentication
:return:
"""
if require_any_permission is None:
require_any_permission = []
from api_graphql.definition import QUERIES
self.field(
field = (
MutationFieldBuilder(name)
.with_resolver(
lambda *args, **kwargs: [
x for x in QUERIES if x.name == f"{mutation_name}Mutation"
][0]
)
.with_require_any_permission(require_any_permission)
.with_public(public)
)
if require_any_permission is not None:
field.with_require_any_permission(require_any_permission)
if require_any is not None:
field.with_require_any(*require_any)
self.field(field)
@staticmethod
async def _resolve_assignments(
foreign_objs: list[int],

View File

@ -79,6 +79,7 @@ class QueryABC(ObjectType):
):
return
resolver_results = []
for x in resolvers:
user = await Route.get_authenticated_user_or_api_key_or_default()
user_permissions = []
@ -86,14 +87,16 @@ class QueryABC(ObjectType):
user_permissions = await user.permissions
if iscoroutinefunction(x):
result = await x(
QueryContext(data, user, user_permissions, *args, **kwargs)
resolver_results.append(
await x(QueryContext(data, user, user_permissions, *args, **kwargs))
)
else:
result = x(QueryContext(data, user, user_permissions, *args, **kwargs))
resolver_results.append(
x(QueryContext(data, user, user_permissions, *args, **kwargs))
)
if not result:
raise AccessDenied()
if not any(resolver_results):
raise AccessDenied()
def field(
self,

View File

@ -1,4 +1,5 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.require_any_resolvers import by_user_setup_mutation
from service.permission.permissions_enum import Permissions
@ -54,11 +55,14 @@ class Mutation(MutationABC):
self.add_mutation_type(
"shortUrl",
"ShortUrl",
require_any_permission=[
Permissions.short_urls_create,
Permissions.short_urls_update,
Permissions.short_urls_delete,
],
require_any=(
[
Permissions.short_urls_create,
Permissions.short_urls_update,
Permissions.short_urls_delete,
],
[by_user_setup_mutation],
),
)
self.add_mutation_type(

View File

@ -1,7 +1,9 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.short_url_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput
from api_graphql.require_any_resolvers import by_user_setup_mutation
from core.configuration.feature_flags import FeatureFlags
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from core.logger import APILogger
@ -20,32 +22,36 @@ class ShortUrlMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "ShortUrl")
self.mutation(
"create",
self.resolve_create,
ShortUrlCreateInput,
require_any_permission=[Permissions.short_urls_create],
self.field(
MutationFieldBuilder("create")
.with_resolver(self.resolve_create)
.with_input(ShortUrlCreateInput)
.with_require_any([Permissions.short_urls_create], [by_user_setup_mutation])
)
self.mutation(
"update",
self.resolve_update,
ShortUrlUpdateInput,
require_any_permission=[Permissions.short_urls_update],
self.field(
MutationFieldBuilder("update")
.with_resolver(self.resolve_update)
.with_input(ShortUrlUpdateInput)
.with_require_any([Permissions.short_urls_update], [by_user_setup_mutation])
)
self.mutation(
"delete",
self.resolve_delete,
require_any_permission=[Permissions.short_urls_delete],
self.field(
MutationFieldBuilder("delete")
.with_resolver(self.resolve_delete)
.with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation])
)
self.mutation(
"restore",
self.resolve_restore,
require_any_permission=[Permissions.short_urls_delete],
self.field(
MutationFieldBuilder("restore")
.with_resolver(self.resolve_restore)
.with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation])
)
self.mutation(
"trackVisit",
self.resolve_track_visit,
require_any_permission=[Permissions.short_urls_update],
self.field(
MutationFieldBuilder("trackVisit")
.with_resolver(self.resolve_track_visit)
.with_require_any_permission([Permissions.short_urls_update])
)
@staticmethod

View File

@ -131,11 +131,11 @@ class Query(QueryABC):
)
if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup):
group_field = group_field.with_default_filter(self._resolve_default_user_filter)
group_field = group_field.with_default_filter(
self._resolve_default_user_filter
)
self.field(
group_field
)
self.field(group_field)
short_url_field = (
DaoFieldBuilder("shortUrls")
@ -149,11 +149,11 @@ class Query(QueryABC):
)
if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup):
short_url_field = short_url_field.with_default_filter(self._resolve_default_user_filter)
short_url_field = short_url_field.with_default_filter(
self._resolve_default_user_filter
)
self.field(
short_url_field
)
self.field(short_url_field)
self.field(
ResolverFieldBuilder("settings")

View File

@ -37,3 +37,10 @@ async def by_user_setup_resolver(ctx: QueryContext) -> bool:
return False
return all(x.user_setup_id == ctx.user.id for x in ctx.data.nodes)
async def by_user_setup_mutation(ctx: QueryContext) -> bool:
if not FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup):
return False
return all(x.user_setup_id == ctx.user.id for x in ctx.data.nodes)

View File

@ -44,7 +44,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
self._default_filter_condition = None
self.__attributes: dict[str, type] = {}
self.__joins: dict[str, str] = {}
self.__joins: list[str] = []
self.__db_names: dict[str, str] = {}
self.__foreign_tables: dict[str, str] = {}
@ -138,9 +138,23 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if table_name == self._table_name:
return
self.__joins[foreign_attr] = (
f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
)
if any(f"{table_name} ON" in join for join in self.__joins):
index = next(
(
i
for i, join in enumerate(self.__joins)
if f"{table_name} ON" in join
),
None,
)
if index is not None:
self.__joins[
index
] += f" AND {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
else:
self.__joins.append(
f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
)
self.__foreign_tables[attr] = table_name
def use_external_fields(self, builder: ExternalDataTempTableBuilder):
@ -168,8 +182,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
async def count(self, filters: AttributeFilters = None) -> int:
query = f"SELECT COUNT(*) FROM {self._table_name}"
for join in self.__joins:
query += f" {self.__joins[join]}"
join_str = f" ".join(self.__joins)
query += f" {join_str}" if len(join_str) > 0 else ""
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
conditions, external_table_deps = await self._build_conditions(filters)
@ -192,8 +206,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
without_deleted=False,
) -> list[T_DBM]:
query = f"SELECT {self._table_name}_history.* FROM {self._table_name}_history"
for join in self.__joins:
query += f" {self.__joins[join].replace(self._table_name, f'{self._table_name}_history')}"
query += f" {join.replace(self._table_name, f'{self._table_name}_history')}"
query += f" WHERE {f'{self._table_name}_history.{self.__primary_key}' if by_key is None else f'{self._table_name}_history.{by_key}'} = {entry_id}"
@ -593,8 +608,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
external_table_deps = []
query = f"SELECT {self._table_name}.* FROM {self._table_name}"
for join in self.__joins:
query += f" {self.__joins[join]}"
join_str = f" ".join(self.__joins)
query += f" {join_str}" if len(join_str) > 0 else ""
# Collect dependencies from filters
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):

View File

@ -54,7 +54,7 @@ class DbModelABC(ABC):
from data.schemas.administration.user_dao import userDao
return await userDao.find_single_by({"id": self._editor_id})
return await userDao.get_by_id(self._editor_id)
@property
def created(self) -> datetime:
@ -63,3 +63,7 @@ class DbModelABC(ABC):
@property
def updated(self) -> datetime:
return self._updated
@updated.setter
def updated(self, value: datetime):
self._updated = value

View File

@ -14,6 +14,15 @@ class DbModelDaoABC[T_DBM](DataAccessObjectABC[T_DBM]):
self.attribute(DbModelABC.id, int, ignore=True)
self.attribute(DbModelABC.deleted, bool)
self.attribute(DbModelABC.editor_id, int, ignore=True)
self.attribute(DbModelABC.created, datetime, "created", ignore=True)
self.attribute(DbModelABC.updated, datetime, "updated", ignore=True)
self.attribute(DbModelABC.editor_id, int, ignore=True) # handled by db trigger
self.reference(
"editor", "id", DbModelABC.editor_id, "administration.users"
) # not relevant for updates due to editor_id
self.attribute(
DbModelABC.created, datetime, ignore=True
) # handled by db trigger
self.attribute(
DbModelABC.updated, datetime, ignore=True
) # handled by db trigger

View File

@ -1,9 +1,4 @@
from core.database.database_settings import DatabaseSettings
from core.database.db_context import DBContext
from core.environment import Environment
from core.logger import DBLogger
logger = DBLogger(__name__)
class Database:
@ -19,30 +14,3 @@ class Database:
@classmethod
def connect(cls):
cls._db.connect()
@classmethod
async def startup_db(cls):
from data.service.migration_service import MigrationService
logger.info("Init DB")
db = DBContext()
host = Environment.get("DB_HOST", str)
port = Environment.get("DB_PORT", int)
user = Environment.get("DB_USER", str)
password = Environment.get("DB_PASSWORD", str)
database = Environment.get("DB_DATABASE", str)
if None in [host, port, user, password, database]:
logger.fatal(
"DB settings are not set correctly",
EnvironmentError("DB settings are not set correctly"),
)
await db.connect(
DatabaseSettings(
host=host, port=port, user=user, password=password, database=database
)
)
Database.init(db)
migrations = MigrationService(db)
await migrations.migrate()

View File

@ -1,6 +1,9 @@
import uuid
from typing import Optional, Any
from psycopg import OperationalError
from psycopg_pool import PoolTimeout
from core.database.database_settings import DatabaseSettings
from core.database.postgres_pool import PostgresPool
from core.environment import Environment
@ -26,15 +29,15 @@ class DBContext:
except Exception as e:
logger.fatal("Connecting to database failed", e)
async def execute(self, statement: str, args=None) -> list[list]:
async def execute(self, statement: str, args=None, multi=True) -> list[list]:
logger.trace(f"execute {statement} with args: {args}")
return await self._pool.execute(statement, args)
return await self._pool.execute(statement, args, multi)
async def select_map(self, statement: str, args=None) -> list[dict]:
logger.trace(f"select {statement} with args: {args}")
try:
return await self._pool.select_map(statement, args)
except Exception as e:
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
uid = uuid.uuid4()
@ -50,6 +53,9 @@ class DBContext:
except Exception as e:
pass
return []
except Exception as e:
logger.error(f"Database error caused by {statement}", e)
raise e
async def select(
self, statement: str, args=None
@ -57,7 +63,7 @@ class DBContext:
logger.trace(f"select {statement} with args: {args}")
try:
return await self._pool.select(statement, args)
except Exception as e:
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
uid = uuid.uuid4()
@ -73,3 +79,6 @@ class DBContext:
except Exception as e:
pass
return []
except Exception as e:
logger.error(f"Database error caused by {statement}", e)
raise e

View File

@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Any
from psycopg import sql
from psycopg_pool import AsyncConnectionPool, PoolTimeout
@ -21,7 +21,7 @@ class PostgresPool:
f"host={database_settings.host} "
f"port={database_settings.port} "
f"user={database_settings.user} "
f"password={B64Helper.decode(database_settings.password)} "
f"password={database_settings.password} "
f"dbname={database_settings.database}"
)
self._pool_size = pool_size
@ -41,18 +41,31 @@ class PostgresPool:
logger.fatal(f"Failed to connect to the database", e)
return pool
async def execute(self, query: str, args=None) -> list[list]:
@staticmethod
async def _exec_sql(cursor: Any, query: str, args=None, multi=True):
if multi:
queries = query.split(";")
for q in queries:
if q.strip() == "":
continue
await cursor.execute(sql.SQL(q), args)
else:
await cursor.execute(sql.SQL(query), args)
async def execute(self, query: str, args=None, multi=True) -> list[list]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
if (
cursor.description is not None
@ -68,34 +81,36 @@ class PostgresPool:
else:
return []
async def select(self, query: str, args=None) -> list[str]:
async def select(self, query: str, args=None, multi=True) -> list[str]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
return list(res)
async def select_map(self, query: str, args=None) -> list[dict]:
async def select_map(self, query: str, args=None, multi=True) -> list[dict]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
res_map: list[dict] = []

View File

@ -77,7 +77,7 @@ class MigrationService:
logger.debug(f"Running upgrade migration: {migration.name}")
await self._db.execute(migration.script)
await self._db.execute(migration.script, multi=False)
await executedMigrationDao.create(
ExecutedMigration(migration.name), skip_editor=True

View File

@ -4,6 +4,7 @@ import { Logger } from 'src/app/service/logger.service';
import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
const log = new Logger('PermissionGuard');
@ -14,11 +15,17 @@ export class PermissionGuard {
constructor(
private router: Router,
private toast: ToastService,
private auth: AuthService
private auth: AuthService,
private features: FeatureFlagService
) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data['permissions'] as PermissionsEnum[];
const checkByPerUserSetup = route.data['checkByPerUserSetup'] as boolean;
if (checkByPerUserSetup) {
return await this.features.get('PerUserSetup');
}
if (!permissions || permissions.length === 0) {
return true;

View File

@ -22,7 +22,7 @@ const routes: Routes = [
m => m.GroupsModule
),
canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.groups] },
data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true },
},
{
path: 'urls',
@ -36,6 +36,7 @@ const routes: Routes = [
PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
],
checkByPerUserSetup: true,
},
},
{

View File

@ -65,7 +65,7 @@
></p-dropdown>
</div>
</div>
<div class="form-page-input">
<div class="form-page-input" *ngIf="!isPerUserSetup">
<p class="label">{{ 'common.domain' | translate }}</p>
<div
class="value">

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base';
@ -10,29 +10,45 @@ import {
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-short-url-form-page',
templateUrl: './short-url-form-page.component.html',
styleUrl: './short-url-form-page.component.scss',
})
export class ShortUrlFormPageComponent extends FormPageBase<
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
ShortUrlsDataService
> {
export class ShortUrlFormPageComponent
extends FormPageBase<
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
ShortUrlsDataService
>
implements OnInit
{
groups: Group[] = [];
domains: Domain[] = [];
constructor(private toast: ToastService) {
isPerUserSetup = true;
constructor(
private features: FeatureFlagService,
private toast: ToastService
) {
super();
this.dataService.getAllGroups().subscribe(groups => {
this.groups = groups;
});
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
}
async ngOnInit() {
this.isPerUserSetup = await this.features.get('PerUserSetup');
if (!this.isPerUserSetup) {
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
}
if (!this.nodeId) {
this.node = this.new();

View File

@ -22,6 +22,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsCreate],
checkByPerUserSetup: true,
},
},
{
@ -30,6 +31,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsUpdate],
checkByPerUserSetup: true,
},
},
{
@ -37,7 +39,8 @@ const routes: Routes = [
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.domains],
permissions: [PermissionsEnum.shortUrls],
checkByPerUserSetup: true,
},
},
],

View File

@ -12,6 +12,7 @@ import QrCodeWithLogo from 'qrcode-with-logos';
import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload';
import { ConfigService } from 'src/app/service/config.service';
import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-short-urls',
@ -67,7 +68,8 @@ export class ShortUrlsPage
private toast: ToastService,
private confirmation: ConfirmationDialogService,
private auth: AuthService,
private config: ConfigService
private config: ConfigService,
private features: FeatureFlagService
) {
super(true, {
read: [PermissionsEnum.shortUrls],
@ -80,21 +82,26 @@ export class ShortUrlsPage
async ngOnInit() {
this.hasPermissions = {
read: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.read ?? []
),
create: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.create ?? []
),
update: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.update ?? []
),
delete: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.delete ?? []
),
restore: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.restore ?? []
),
read:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.read ?? []
)) || (await this.features.get('PerUserSetup')),
create:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.create ?? []
)) || (await this.features.get('PerUserSetup')),
update:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.update ?? []
)) || (await this.features.get('PerUserSetup')),
delete:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.delete ?? []
)) || (await this.features.get('PerUserSetup')),
restore:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.restore ?? []
)) || (await this.features.get('PerUserSetup')),
};
}