feat(core): guard service (#9816)
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapWithTrailing,
|
||||
@@ -12,18 +10,18 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { WorkspacePermissionStore } from '../stores/permission';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspacePermission extends Entity {
|
||||
isOwner$ = new LiveData<boolean | null>(null);
|
||||
isAdmin$ = new LiveData<boolean | null>(null);
|
||||
isTeam$ = new LiveData<boolean | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private readonly cache$ = LiveData.from(
|
||||
this.store.watchWorkspacePermissionCache(),
|
||||
undefined
|
||||
);
|
||||
isOwner$ = this.cache$.map(cache => cache?.isOwner ?? null);
|
||||
isAdmin$ = this.cache$.map(cache => cache?.isAdmin ?? null);
|
||||
isTeam$ = this.cache$.map(cache => cache?.isTeam ?? null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@@ -51,27 +49,30 @@ export class WorkspacePermission extends Entity {
|
||||
}
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(({ isOwner, isAdmin, isTeam }) => {
|
||||
this.isAdmin$.next(isAdmin);
|
||||
this.isOwner$.next(isOwner);
|
||||
this.isTeam$.next(isTeam);
|
||||
this.store.setWorkspacePermissionCache({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
isTeam,
|
||||
});
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
onStart(() => this.isLoading$.setValue(true)),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
onStart(() => this.isRevalidating$.setValue(true)),
|
||||
onComplete(() => this.isRevalidating$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
async waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(
|
||||
isRevalidating => !isRevalidating,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ export {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from './services/doc-granted-users';
|
||||
export { GuardService } from './services/guard';
|
||||
export { MemberSearchService } from './services/member-search';
|
||||
export { WorkspaceMembersService } from './services/members';
|
||||
export { WorkspacePermissionService } from './services/permission';
|
||||
export {
|
||||
type DocPermissionActions,
|
||||
type WorkspacePermissionActions,
|
||||
} from './stores/guard';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { DocScope, DocService } from '../doc';
|
||||
import {
|
||||
WorkspaceLocalState,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
WorkspacesService,
|
||||
@@ -19,10 +25,12 @@ import {
|
||||
import { WorkspaceMembers } from './entities/members';
|
||||
import { WorkspacePermission } from './entities/permission';
|
||||
import { DocGrantedUsersService } from './services/doc-granted-users';
|
||||
import { GuardService } from './services/guard';
|
||||
import { MemberSearchService } from './services/member-search';
|
||||
import { WorkspaceMembersService } from './services/members';
|
||||
import { WorkspacePermissionService } from './services/permission';
|
||||
import { DocGrantedUsersStore } from './stores/doc-granted-users';
|
||||
import { GuardStore } from './stores/guard';
|
||||
import { MemberSearchStore } from './stores/member-search';
|
||||
import { WorkspaceMembersStore } from './stores/members';
|
||||
import { WorkspacePermissionStore } from './stores/permission';
|
||||
@@ -35,13 +43,22 @@ export function configurePermissionsModule(framework: Framework) {
|
||||
WorkspacesService,
|
||||
WorkspacePermissionStore,
|
||||
])
|
||||
.store(WorkspacePermissionStore, [WorkspaceServerService])
|
||||
.store(WorkspacePermissionStore, [
|
||||
WorkspaceServerService,
|
||||
WorkspaceLocalState,
|
||||
])
|
||||
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
|
||||
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
|
||||
.store(WorkspaceMembersStore, [WorkspaceServerService])
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
|
||||
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
|
||||
.store(MemberSearchStore, [WorkspaceServerService]);
|
||||
.store(MemberSearchStore, [WorkspaceServerService])
|
||||
.service(GuardService, [
|
||||
GuardStore,
|
||||
WorkspaceService,
|
||||
WorkspacePermissionService,
|
||||
])
|
||||
.store(GuardStore, [WorkspaceService, WorkspaceServerService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
|
||||
181
packages/frontend/core/src/modules/permissions/services/guard.ts
Normal file
181
packages/frontend/core/src/modules/permissions/services/guard.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import {
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
} from 'rxjs';
|
||||
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type {
|
||||
DocPermissionActions,
|
||||
GuardStore,
|
||||
WorkspacePermissionActions,
|
||||
} from '../stores/guard';
|
||||
import type { WorkspacePermissionService } from './permission';
|
||||
|
||||
export class GuardService extends Service {
|
||||
constructor(
|
||||
private readonly guardStore: GuardStore,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspacePermissionService: WorkspacePermissionService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly workspacePermissions$ = new LiveData<
|
||||
Partial<Record<WorkspacePermissionActions, boolean>>
|
||||
>({});
|
||||
|
||||
private readonly docPermissions$ = new LiveData<
|
||||
Record<string, Partial<Record<DocPermissionActions, boolean>>>
|
||||
>({});
|
||||
|
||||
private readonly isAdmin$ = LiveData.computed(get => {
|
||||
const isOwner = get(this.workspacePermissionService.permission.isOwner$);
|
||||
const isAdmin = get(this.workspacePermissionService.permission.isAdmin$);
|
||||
if (isOwner === null && isAdmin === null) {
|
||||
return null;
|
||||
}
|
||||
return isOwner || isAdmin;
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* guardService.can$('Workspace_Properties_Update');
|
||||
* guardService.can$('Doc_Update', docId);
|
||||
* ```
|
||||
*/
|
||||
can$<T extends WorkspacePermissionActions | DocPermissionActions>(
|
||||
action: T,
|
||||
...args: T extends DocPermissionActions ? [string] : []
|
||||
): LiveData<boolean> {
|
||||
const docId = args[0];
|
||||
return LiveData.from(
|
||||
new Observable(subscriber => {
|
||||
// revalidate permission
|
||||
if (docId) {
|
||||
this.revalidateDocPermission(docId);
|
||||
} else {
|
||||
this.revalidateWorkspacePermission();
|
||||
}
|
||||
// revalidate workspace permission if it's not initialized
|
||||
if (this.isAdmin$.value === null) {
|
||||
this.workspacePermissionService.permission.revalidate();
|
||||
}
|
||||
|
||||
let prev = false;
|
||||
|
||||
const subscription = combineLatest([
|
||||
(docId
|
||||
? this.docPermissions$.pipe(
|
||||
map(permissions => permissions[docId] ?? false)
|
||||
)
|
||||
: this.workspacePermissions$) as Observable<
|
||||
Record<string, boolean>
|
||||
>,
|
||||
this.isAdmin$,
|
||||
]).subscribe(([permissions, isAdmin]) => {
|
||||
if (isAdmin) {
|
||||
return subscriber.next(true);
|
||||
}
|
||||
const current = permissions[action] ?? false;
|
||||
if (current !== prev) {
|
||||
prev = current;
|
||||
subscriber.next(current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
async can<T extends WorkspacePermissionActions | DocPermissionActions>(
|
||||
action: T,
|
||||
...args: T extends DocPermissionActions ? [string] : []
|
||||
): Promise<boolean> {
|
||||
const docId = args[0];
|
||||
|
||||
if (this.isAdmin$.value === null) {
|
||||
await this.workspacePermissionService.permission.waitForRevalidation();
|
||||
}
|
||||
|
||||
if (this.isAdmin$.value === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = await (docId
|
||||
? this.loadDocPermission(docId)
|
||||
: this.loadWorkspacePermission());
|
||||
|
||||
return permissions[action as keyof typeof permissions] ?? false;
|
||||
}
|
||||
|
||||
private readonly revalidateWorkspacePermission = effect(
|
||||
exhaustMapWithTrailing(() =>
|
||||
fromPromise(() => this.guardStore.getWorkspacePermissions()).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly revalidateDocPermission = effect(
|
||||
groupBy((docId: string) => docId),
|
||||
mergeMap(doc$ =>
|
||||
doc$.pipe(
|
||||
exhaustMap((docId: string) =>
|
||||
fromPromise(() => this.loadDocPermission(docId)).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly loadWorkspacePermission = async () => {
|
||||
if (this.workspaceService.workspace.flavour === 'local') {
|
||||
return {} as Record<WorkspacePermissionActions, boolean>;
|
||||
}
|
||||
const permissions = await this.guardStore.getWorkspacePermissions();
|
||||
this.workspacePermissions$.next(permissions);
|
||||
return permissions;
|
||||
};
|
||||
|
||||
private readonly loadDocPermission = async (docId: string) => {
|
||||
if (this.workspaceService.workspace.flavour === 'local') {
|
||||
return {} as Record<DocPermissionActions, boolean>;
|
||||
}
|
||||
const permissions = await this.guardStore.getDocPermissions(docId);
|
||||
this.docPermissions$.next({
|
||||
...this.docPermissions$.value,
|
||||
[docId]: permissions,
|
||||
});
|
||||
return permissions;
|
||||
};
|
||||
|
||||
override dispose() {
|
||||
this.revalidateWorkspacePermission.unsubscribe();
|
||||
this.revalidateDocPermission.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type GetDocRolePermissionsQuery,
|
||||
getDocRolePermissionsQuery,
|
||||
getWorkspaceRolePermissionsQuery,
|
||||
type WorkspacePermissions,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export type WorkspacePermissionActions = keyof Omit<
|
||||
WorkspacePermissions,
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export type DocPermissionActions = keyof Omit<
|
||||
GetDocRolePermissionsQuery['workspace']['doc']['permissions'],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export class GuardStore extends Store {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getWorkspacePermissions(): Promise<
|
||||
Record<WorkspacePermissionActions, boolean>
|
||||
> {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceRolePermissionsQuery,
|
||||
variables: {
|
||||
id: this.workspaceService.workspace.id,
|
||||
},
|
||||
});
|
||||
return data.workspaceRolePermissions.permissions;
|
||||
}
|
||||
|
||||
async getDocPermissions(
|
||||
docId: string
|
||||
): Promise<Record<DocPermissionActions, boolean>> {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getDocRolePermissionsQuery,
|
||||
variables: {
|
||||
workspaceId: this.workspaceService.workspace.id,
|
||||
docId,
|
||||
},
|
||||
});
|
||||
return data.workspace.doc.permissions;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { getWorkspaceInfoQuery, leaveWorkspaceMutation } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceLocalState } from '../../workspace';
|
||||
|
||||
export class WorkspacePermissionStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
constructor(
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly workspaceLocalState: WorkspaceLocalState
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -36,4 +41,20 @@ export class WorkspacePermissionStore extends Store {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watchWorkspacePermissionCache() {
|
||||
return this.workspaceLocalState.watch<{
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
isTeam: boolean;
|
||||
}>('permission');
|
||||
}
|
||||
|
||||
setWorkspacePermissionCache(permission: {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
isTeam: boolean;
|
||||
}) {
|
||||
this.workspaceLocalState.set('permission', permission);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user