Skip to content

Commit 26bdff8

Browse files
authored
Merge pull request #5754 from cloudforet-io/feature-scoped-query-composable
feat(scoped-query): introduce useScopedQuery for scope-based API access control
2 parents 552ca88 + cc6e8ee commit 26bdff8

File tree

12 files changed

+148
-95
lines changed

12 files changed

+148
-95
lines changed

Diff for: apps/web/src/api-clients/_common/composables/use-scoped-query.ts

-82
This file was deleted.

Diff for: apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { computed } from 'vue';
33

44
import type { QueryKey } from '@tanstack/vue-query';
55

6-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
76
import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api';
87
import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api';
98
import type { DataTableListParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/list';
109
import type { DataTableUpdateParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/update';
10+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1111
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1212

1313
import type { DataTableModel } from '@/common/modules/widgets/types/widget-data-table-type';

Diff for: apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { ComputedRef } from 'vue';
22
import { computed } from 'vue';
33

4-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
54
import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api';
65
import type { DataTableGetParameters } from '@/api-clients/dashboard/private-data-table/schema/api-verbs/get';
76
import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api';
7+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
88
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
99

1010

Diff for: apps/web/src/common/modules/widgets/_composables/use-widget-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { computed } from 'vue';
33

44
import type { QueryKey } from '@tanstack/vue-query';
55

6-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
76
import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type';
87
import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api';
98
import type { PrivateWidgetGetParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/get';
109
import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/composables/use-public-widget-api';
1110
import type { PublicWidgetGetParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/get';
11+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1212
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1313

1414

Diff for: apps/web/src/query/composables/use-scoped-query.ts

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching.
3+
*
4+
* ## Why this hook exists?
5+
* This hook was created to integrate **scope-based API access control** with Vue Query.
6+
* It ensures that queries are only executed when the user's granted scope matches the required scope.
7+
* Additionally, it automatically handles loading states and prevents unnecessary queries.
8+
*
9+
* ## Functionality
10+
* - Extends `useQuery` with **grant scope validation**.
11+
* - Runs queries only when **the user's scope is valid** and the app is **ready**.
12+
* - Uses Vue's **reactivity** to dynamically compute the `enabled` state.
13+
* - Supports both **static and reactive `enabled` values**.
14+
*
15+
* ## Parameters:
16+
* - `options`: Standard **Vue Query options** (`UseQueryOptions`).
17+
* - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute.
18+
*
19+
* ## Supported Query Options:
20+
* - `queryKey`, `queryFn`, `select`, `initialData`, `staleTime`, `enabled` (static or reactive)
21+
*
22+
* ## Example:
23+
* const query = useScopedQuery(
24+
* {
25+
* queryKey: ['dashboard', dashboardId],
26+
* queryFn: () => fetchDashboardData(dashboardId),
27+
* enabled: computed(() => isUserAuthorized.value),
28+
* },
29+
* ['DOMAIN', 'WORKSPACE']
30+
* );
31+
*/
32+
33+
import type { MaybeRef } from '@vueuse/core';
34+
import { toValue } from '@vueuse/core';
35+
import { computed, type ComputedRef } from 'vue';
36+
37+
import {
38+
useQuery, type UseQueryOptions, type UseQueryReturnType,
39+
} from '@tanstack/vue-query';
40+
41+
import type { GrantScope } from '@/api-clients/identity/token/schema/type';
42+
import type { QueryKeyArray } from '@/query/query-key/_types/query-key-type';
43+
44+
import { useAppContextStore } from '@/store/app-context/app-context-store';
45+
import { useUserStore } from '@/store/user/user-store';
46+
47+
48+
type ScopedEnabled = MaybeRef<boolean>;
49+
50+
51+
export const useScopedQuery = <TQueryFnData, TError = unknown, TData = TQueryFnData>(
52+
options: UseQueryOptions<TQueryFnData, TError, TData>,
53+
requiredScopes: [GrantScope, ...GrantScope[]],
54+
): UseQueryReturnType<TData, TError> => {
55+
// [Dev Warning] This query is missing `requiredScopes`.
56+
// All scoped queries must explicitly define at least one valid scope for clarity and safety.
57+
if (import.meta.env.DEV && (!requiredScopes || requiredScopes.length === 0)) {
58+
_warnOncePerTick(() => {
59+
console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', {
60+
queryKey: _extractQueryKey((options as any).queryKey),
61+
suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.',
62+
});
63+
return true;
64+
});
65+
}
66+
67+
const appContextStore = useAppContextStore();
68+
const userStore = useUserStore();
69+
70+
const currentGrantScope = computed<GrantScope | undefined>(
71+
() => userStore.state.currentGrantInfo?.scope,
72+
);
73+
const isAppReady = computed(() => !appContextStore.getters.globalGrantLoading);
74+
75+
const isValidScope = computed(() => currentGrantScope.value !== undefined
76+
&& requiredScopes.includes(currentGrantScope.value));
77+
78+
const rawEnabled = (options as { enabled?: ScopedEnabled }).enabled;
79+
const queryEnabled = computed(() => {
80+
const inheritedEnabled = rawEnabled !== undefined ? toValue(rawEnabled) : true;
81+
return inheritedEnabled && isValidScope.value && isAppReady.value;
82+
});
83+
84+
// [Dev Warning] The current user's scope is not included in the allowed `requiredScopes`.
85+
// This usually indicates a configuration mistake in the query declaration.
86+
if (import.meta.env.DEV) {
87+
const currentScope = currentGrantScope.value;
88+
if (isAppReady.value && currentScope && !requiredScopes.includes(currentScope)) {
89+
_warnOncePerTick(() => {
90+
console.warn('[useScopedQuery] Invalid requiredScopes for current scope:', {
91+
queryKey: _extractQueryKey((options as any).queryKey),
92+
requiredScopes,
93+
currentScope,
94+
});
95+
return true;
96+
});
97+
}
98+
}
99+
100+
return useQuery<TQueryFnData, TError, TData>({
101+
...options,
102+
enabled: queryEnabled,
103+
});
104+
};
105+
106+
const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as ComputedRef<QueryKeyArray>);
107+
108+
109+
110+
/* Warning Logger Utilities */
111+
const _warnedKeys = new Set<string>();
112+
const _getCallerKey = (): string => {
113+
try {
114+
const err = new Error();
115+
const stack = err.stack?.split('\n') || [];
116+
117+
const caller = stack.find((line, i) => i > 1
118+
&& (line.includes('.ts') || line.includes('.vue'))
119+
&& !line.includes('use-scoped-query'));
120+
121+
return caller?.trim() ?? 'UNKNOWN_CALLSITE';
122+
} catch {
123+
return 'UNKNOWN_CALLSITE';
124+
}
125+
};
126+
const _warnOncePerTick = (log: () => boolean) => {
127+
const key = _getCallerKey();
128+
if (_warnedKeys.has(key)) return;
129+
const didLog = log();
130+
131+
if (didLog) {
132+
_warnedKeys.add(key);
133+
queueMicrotask(() => _warnedKeys.delete(key));
134+
}
135+
};

Diff for: apps/web/src/query/query-key/_types/query-key-type.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { API_DOC } from '@/api-clients/_common/constants/api-doc-constant';
22

33

4-
export type QueryKeyArray = unknown[];
4+
export type QueryKeyArray = readonly unknown[];
55

66
export type QueryScope = 'service' | 'reference';
77

Diff for: apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type { QueryKey } from '@tanstack/vue-query';
77

88
import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper';
99

10-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
1110
import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list';
1211
import type { FolderModel, FolderUpdateParams } from '@/api-clients/dashboard/_types/folder-type';
1312
import { usePrivateFolderApi } from '@/api-clients/dashboard/private-folder/composables/use-private-folder-api';
@@ -16,6 +15,7 @@ import type { PrivateFolderModel } from '@/api-clients/dashboard/private-folder/
1615
import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api';
1716
import type { PublicFolderUpdateParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/update';
1817
import type { PublicFolderModel } from '@/api-clients/dashboard/public-folder/schema/model';
18+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1919
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
2020

2121
import { useAppContextStore } from '@/store/app-context/app-context-store';

Diff for: apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { computed } from 'vue';
33

44
import type { QueryKey } from '@tanstack/vue-query';
55

6-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
76
import type { DashboardModel, DashboardUpdateParams } from '@/api-clients/dashboard/_types/dashboard-type';
87
import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api';
98
import type { PrivateDashboardGetParameters } from '@/api-clients/dashboard/private-dashboard/schema/api-verbs/get';
@@ -13,6 +12,7 @@ import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/
1312
import type { PublicDashboardGetParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/get';
1413
import type { PublicDashboardUpdateParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/update';
1514
import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model';
15+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1616
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1717

1818
const STALE_TIME = 1000 * 60 * 5;

Diff for: apps/web/src/services/dashboards/composables/use-dashboard-query.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import type { QueryKey } from '@tanstack/vue-query';
77

88
import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper';
99

10-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
11-
import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list';
1210
import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api';
1311
import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model';
1412
import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api';
1513
import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model';
14+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1615
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1716

1817
import { useAppContextStore } from '@/store/app-context/app-context-store';
1918

19+
2020
const DEFAULT_LIST_DATA = { results: [] };
2121
const STALE_TIME = 1000 * 60 * 5;
2222

@@ -67,7 +67,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => {
6767
});
6868

6969
/* Querys */
70-
const publicDashboardListQuery = useScopedQuery<ListResponse<PublicDashboardModel>, unknown, PublicDashboardModel[]>({
70+
const publicDashboardListQuery = useScopedQuery({
7171
queryKey: publicDashboardListQueryKey,
7272
queryFn: () => publicDashboardAPI.list(publicDashboardListParams.value),
7373
select: (data) => data?.results ?? [],
@@ -76,7 +76,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => {
7676
staleTime: STALE_TIME,
7777
enabled: computed(() => !!_state.publicDashboardListApiQuery?.filter),
7878
}, ['DOMAIN', 'WORKSPACE']);
79-
const privateDashboardListQuery = useScopedQuery<ListResponse<PrivateDashboardModel>, unknown, PrivateDashboardModel[]>({
79+
const privateDashboardListQuery = useScopedQuery({
8080
queryKey: privateDashboardListQueryKey,
8181
queryFn: () => privateDashboardAPI.list(privateDashboardListParams.value),
8282
select: (data) => data?.results || [],

Diff for: apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import type { ConsoleFilter } from '@cloudforet/core-lib/query/type';
99
import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper';
1010
import type { Query } from '@cloudforet/core-lib/space-connector/type';
1111

12-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
1312
import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list';
1413
import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api';
1514
import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model';
1615
import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api';
1716
import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model';
1817
import { ROLE_TYPE } from '@/api-clients/identity/role/constant';
18+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1919
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
2020

2121
import { useAppContextStore } from '@/store/app-context/app-context-store';

Diff for: apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { computed } from 'vue';
33

44
import type { QueryKey } from '@tanstack/vue-query';
55

6-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
76
import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type';
87
import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api';
98
import type { PrivateWidgetListParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/list';
@@ -13,6 +12,7 @@ import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/compos
1312
import type { PublicWidgetListParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/list';
1413
import type { PublicWidgetUpdateParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/update';
1514
import type { PublicWidgetModel } from '@/api-clients/dashboard/public-widget/schema/model';
15+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1616
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1717

1818
const DEFAULT_LIST_DATA = { results: [] };

Diff for: apps/web/src/services/project/v2/components/ProjectDetailTab.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { computed, ref, watch } from 'vue';
44
import { PTab } from '@cloudforet/mirinae';
55
import type { TabItem } from '@cloudforet/mirinae/types/navigation/tabs/tab/type';
66
7-
import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
87
import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api';
98
import type { PublicDashboardListParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/list';
109
import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api';
1110
import type { PublicFolderListParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/list';
11+
import { useScopedQuery } from '@/query/composables/use-scoped-query';
1212
import { useServiceQueryKey } from '@/query/query-key/use-service-query-key';
1313
1414
import ProjectDashboard from '@/services/project/v2/components/ProjectDashboard.vue';

0 commit comments

Comments
 (0)