feat(core): guard service (#9816)

This commit is contained in:
EYHN
2025-02-10 07:26:38 +08:00
committed by GitHub
parent 879157b938
commit 92f4f0c2d9
89 changed files with 1520 additions and 522 deletions

View File

@@ -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();
}

View File

@@ -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)

View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}