Skip to content

Commit 5a3878d

Browse files
authored
Merge pull request #121 from SUSE/feat/extension-visibility-rbac
feat: gate extension visibility on role-based access
2 parents a125b21 + bce7954 commit 5a3878d

11 files changed

Lines changed: 247 additions & 92 deletions

File tree

pkg/aif-ui/pages/About.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default {
3333
this.operatorCommit = notUnknown(data.commit);
3434
this.chartVersion = notUnknown(data.chartVersion);
3535
} catch (e) {
36+
// eslint-disable-next-line no-console
3637
console.warn('[SUSE-AI] failed to fetch operator version', e);
3738
this.operatorVersion = null;
3839
} finally {
@@ -71,7 +72,10 @@ export default {
7172
v-if="!operatorVersionLoaded"
7273
class="text-muted"
7374
>…</span>
74-
<span v-else-if="operatorVersion">{{ operatorVersion }}<span v-if="operatorCommit" class="text-muted"> (commit {{ operatorCommit }})</span></span>
75+
<span v-else-if="operatorVersion">{{ operatorVersion }}<span
76+
v-if="operatorCommit"
77+
class="text-muted"
78+
> (commit {{ operatorCommit }})</span></span>
7579
<span
7680
v-else
7781
class="text-muted"

pkg/aif-ui/product.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,48 +11,97 @@ import {
1111
} from './config/suseai';
1212
import type { RancherStore } from './types/rancher-types';
1313
import { checkOperatorConnection } from './utils/operator-config';
14+
import { canAccessExtension, invalidateAccessCache, CRTB_TYPE, LOCAL_CLUSTER } from './utils/access';
15+
import { logger } from './utils/logger';
1416

1517
export { PRODUCT } from './config/suseai';
1618

17-
export function init($plugin: IPlugin, store: RancherStore) {
18-
const { product, virtualType, basicType, weightType } = $plugin.DSL(store, PRODUCT);
19+
const AIFACTORY_API_GROUP = 'ai-platform.suse.com';
1920

20-
// Register store modules following standard patterns
21+
let removeNavGuard: (() => void) | null = null;
22+
let removeCrtbWatch: (() => void) | null = null;
23+
24+
export function init($plugin: IPlugin, store: RancherStore) {
2125
store.registerModule?.(PRODUCT, suseaiStore);
2226

23-
// Configure product following standard patterns
27+
const { product, virtualType, basicType, weightType } = $plugin.DSL(store, PRODUCT);
28+
2429
product({
25-
icon: 'suseai',
26-
iconHeader: require('./assets/SUSE-AI-Factory-Logo_pos-green-horizontal.svg'),
27-
inStore: SUSEAI_PRODUCT.inStore,
30+
icon: 'suseai',
31+
iconHeader: require('./assets/SUSE-AI-Factory-Logo_pos-green-horizontal.svg'),
32+
inStore: SUSEAI_PRODUCT.inStore,
2833
isMultiClusterApp: true,
2934
showClusterSwitcher: false,
30-
weight: SUSEAI_PRODUCT.weight,
35+
weight: SUSEAI_PRODUCT.weight,
36+
// Both conditions are AND-checked by Rancher's activeProducts getter.
37+
// ifHaveType: users without CRTB schema access (standard users, cluster members)
38+
// never see the icon.
39+
// ifHaveGroup: the ai-platform.suse.com CRDs are management-cluster-scoped;
40+
// downstream cluster owners do not have access to them in the management store,
41+
// so they are excluded despite having CRTB access.
42+
// Navigation is further restricted by the nav guard below.
43+
ifHaveType: CRTB_TYPE,
44+
ifHaveGroup: AIFACTORY_API_GROUP,
3145
to: {
3246
name: `c-cluster-${PRODUCT}-${PAGE_TYPES.OVERVIEW}`,
3347
params: { product: PRODUCT, cluster: MANAGEMENT_CLUSTER },
3448
meta: { product: PRODUCT, cluster: MANAGEMENT_CLUSTER }
3549
}
36-
} as any);
50+
// ifHaveType and ifHaveGroup are valid at runtime but absent from the
51+
// published @rancher/shell DSL TypeScript types.
52+
} as Record<string, unknown>);
3753

38-
// Register virtual types following standard patterns
39-
VIRTUAL_TYPES.forEach(vType => {
40-
virtualType({
41-
name: vType.name,
42-
label: vType.label,
43-
route: vType.route
54+
const router = store.state.$router;
55+
56+
if (router && typeof router.beforeEach === 'function') {
57+
removeNavGuard?.();
58+
removeNavGuard = router.beforeEach(async (to, _from, next) => {
59+
if (!to.name?.toString().startsWith(`c-cluster-${PRODUCT}-`)) return next();
60+
61+
try {
62+
const canAccess = await canAccessExtension(store);
63+
64+
canAccess ? next() : next({ name: 'home' });
65+
} catch (err) {
66+
// canAccessExtension can throw if the management store is reset at
67+
// runtime (logout, session expiry, network failure). Fail closed to
68+
// avoid leaving the router in a hung state with next() never called.
69+
logger.warn('canAccessExtension threw unexpectedly; failing closed', { action: 'nav-guard', data: err });
70+
store.dispatch('growl/error', {
71+
title: 'Access check failed',
72+
message: 'Unable to verify extension access. Please reload the page.',
73+
timeout: 8000,
74+
});
75+
next({ name: 'home' });
76+
}
4477
});
78+
79+
// Invalidate the cached access decision when CRTBs change so a mid-session
80+
// role revocation is caught on the next navigation into the extension.
81+
removeCrtbWatch?.();
82+
removeCrtbWatch = store.watch(
83+
(_, getters) => getters['management/all'](CRTB_TYPE),
84+
() => invalidateAccessCache()
85+
);
86+
}
87+
88+
VIRTUAL_TYPES.forEach(vType => {
89+
virtualType({ name: vType.name, label: vType.label, route: vType.route });
4590
});
4691

47-
// Apply explicit sidebar ordering (higher weight = higher in list).
4892
Object.entries(NAV_WEIGHTS).forEach(([type, weight]) => {
4993
weightType(type, weight, true);
5094
});
5195

52-
// Register basic types
5396
basicType(BASIC_TYPES);
5497

55-
// Warm config cache + connection check in background.
56-
// Fire-and-forget here; page fetch() hooks await the shared promise.
98+
// Prefetch local-namespace CRTBs for users who have CRTB schema access, so
99+
// the first navigation hits the Vuex cache rather than blocking on a network
100+
// request inside the nav guard. Skipped for users without schema access to
101+
// avoid a guaranteed 403 on every login.
102+
if (store.getters?.['management/schemaFor']?.(CRTB_TYPE)) {
103+
void store.dispatch('management/findAll', { type: CRTB_TYPE, opt: { namespaced: LOCAL_CLUSTER } });
104+
}
105+
57106
void checkOperatorConnection();
58107
}

pkg/aif-ui/services/app-lifecycle-service.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88
import { log as logger } from '../utils/logger';
99
import { createErrorHandler, handleSimpleError } from '../utils/error-handler';
1010
import type {
11-
RancherStore,
12-
AppCRD,
13-
InstallationPayload
11+
Dispatchable,
12+
AppCRD
1413
} from '../types/rancher-types';
1514
import { TIMEOUT_VALUES } from '../utils/constants';
1615

@@ -90,7 +89,7 @@ export class AppLifecycleService {
9089
* ```
9190
*/
9291
static async createOrUpgradeApp(
93-
$store: RancherStore,
92+
$store: Dispatchable,
9493
clusterId: string,
9594
namespace: string,
9695
releaseName: string,
@@ -328,7 +327,7 @@ export class AppLifecycleService {
328327
* ```
329328
*/
330329
static async waitForAppInstall(
331-
$store: RancherStore,
330+
$store: Dispatchable,
332331
clusterId: string,
333332
namespace: string,
334333
releaseName: string,
@@ -450,7 +449,7 @@ export class AppLifecycleService {
450449
* ```
451450
*/
452451
static async deleteApp(
453-
$store: RancherStore,
452+
$store: Dispatchable,
454453
clusterId: string,
455454
namespace: string,
456455
releaseName: string,

pkg/aif-ui/services/chart-service.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getClusterContext } from '../utils/cluster-operations';
55
import { filterAndSortVersions } from '../utils/chart-version';
66
import { TIMEOUT_VALUES } from '../utils/constants';
77
import type {
8-
RancherStore,
8+
Dispatchable,
99
ClusterResource,
1010
RepositoryIndex,
1111
FileEntry
@@ -50,7 +50,7 @@ export class ChartService {
5050
/**
5151
* Get repository index link
5252
*/
53-
private static async getRepoIndexLink($store: RancherStore, repoName: string): Promise<string | null> {
53+
private static async getRepoIndexLink($store: Dispatchable, repoName: string): Promise<string | null> {
5454

5555
const found = await getClusterContext($store, { repoName: repoName});
5656
if (!found) {
@@ -84,7 +84,7 @@ export class ChartService {
8484
/**
8585
* Get repository index data
8686
*/
87-
private static async getRepoIndex($store: RancherStore, repoName: string): Promise<RepositoryIndex | null> {
87+
private static async getRepoIndex($store: Dispatchable, repoName: string): Promise<RepositoryIndex | null> {
8888
const indexLink = await this.getRepoIndexLink($store, repoName);
8989
if (!indexLink) return null;
9090

@@ -112,7 +112,7 @@ export class ChartService {
112112
/**
113113
* List cluster repositories
114114
*/
115-
private static async listClusterRepos($store: RancherStore): Promise<ClusterResource[]> {
115+
private static async listClusterRepos($store: Dispatchable): Promise<ClusterResource[]> {
116116
const { baseApi } = await getClusterContext($store);
117117

118118
try {
@@ -133,7 +133,7 @@ export class ChartService {
133133
* Find chart in repository by slug name
134134
*/
135135
static async findChartInRepo(
136-
$store: RancherStore,
136+
$store: Dispatchable,
137137
_repoClusterId: string,
138138
repoName: string,
139139
slug: string
@@ -169,7 +169,7 @@ export class ChartService {
169169
* List available versions for a chart
170170
*/
171171
static async listChartVersions(
172-
$store: RancherStore,
172+
$store: Dispatchable,
173173
_repoClusterId: string,
174174
repoName: string,
175175
chartName: string
@@ -203,7 +203,7 @@ export class ChartService {
203203
* Fetch default values for a chart
204204
*/
205205
static async fetchChartDefaultValues(
206-
$store: RancherStore,
206+
$store: Dispatchable,
207207
_repoClusterId: string,
208208
repoName: string,
209209
chartName: string,
@@ -225,7 +225,7 @@ export class ChartService {
225225
* Infer appropriate cluster repository for a chart
226226
*/
227227
static async inferClusterRepoForChart(
228-
$store: RancherStore,
228+
$store: Dispatchable,
229229
chartName: string,
230230
preferVersion?: string
231231
): Promise<string | null> {

pkg/aif-ui/services/chart-values.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Replaces complex fallback chains with simple, reliable endpoints
44
*/
55

6-
import type { RancherStore } from '../types/rancher-types';
6+
import type { Dispatchable } from '../types/rancher-types';
77
import { logger } from '../utils/logger';
88
import { getClusterContext } from '../utils/cluster-operations';
99
import { TIMEOUT_VALUES } from '../utils/constants';
@@ -86,9 +86,9 @@ export async function extractFileFromTarGz(buffer: ArrayBuffer, filenameSuffix:
8686
}
8787

8888
export class ChartValuesService {
89-
private store: RancherStore;
89+
private store: Dispatchable;
9090

91-
constructor(store: RancherStore) {
91+
constructor(store: Dispatchable) {
9292
this.store = store;
9393
}
9494

@@ -492,6 +492,6 @@ export class ChartValuesService {
492492
/**
493493
* Factory function to create ChartValuesService instance
494494
*/
495-
export function createChartValuesService(store: RancherStore): ChartValuesService {
495+
export function createChartValuesService(store: Dispatchable): ChartValuesService {
496496
return new ChartValuesService(store);
497497
}

pkg/aif-ui/services/cluster-resources.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Cluster resource metrics service
2-
import type { RancherStore, ClusterResource, ClusterInfo, NodeResource, NodeMetric } from '../types/rancher-types';
2+
import type { Dispatchable, ClusterResource, ClusterInfo, NodeResource, NodeMetric } from '../types/rancher-types';
33
import { handleSimpleError } from '../utils/error-handler';
44
import { TIMEOUT_VALUES } from '../utils/constants';
5+
import logger from '../utils/logger';
56

67
export interface NodeResourceInfo {
78
nodeId: string;
@@ -27,8 +28,8 @@ export interface ClusterResourceSummary {
2728
nodes: NodeResourceInfo[];
2829
}
2930

30-
export async function getClusterResourceMetrics(store: RancherStore, clusterId: string): Promise<ClusterResourceSummary> {
31-
console.log(`[SUSE-AI] getClusterResourceMetrics: Starting for cluster ${clusterId}`);
31+
export async function getClusterResourceMetrics(store: Dispatchable, clusterId: string): Promise<ClusterResourceSummary> {
32+
logger.debug(`getClusterResourceMetrics: Starting for cluster ${clusterId}`);
3233

3334
try {
3435
// Get cluster basic info first using the same approach as getClusters
@@ -210,8 +211,8 @@ export async function getClusterResourceMetrics(store: RancherStore, clusterId:
210211
}
211212
}
212213

213-
export async function getAllClusterResourceMetrics(store: RancherStore): Promise<ClusterResourceSummary[]> {
214-
console.log('[SUSE-AI] getAllClusterResourceMetrics: Starting...');
214+
export async function getAllClusterResourceMetrics(store: Dispatchable): Promise<ClusterResourceSummary[]> {
215+
logger.debug('getAllClusterResourceMetrics: Starting...');
215216

216217
try {
217218
const { getAllClusters } = await import('./rancher-apps');
@@ -275,16 +276,16 @@ function parseK8sMemory(memoryStr: string): number {
275276
}
276277
}
277278

278-
async function fetchNodes(store: RancherStore, clusterId: string): Promise<NodeResource[]> {
279+
async function fetchNodes(store: Dispatchable, clusterId: string): Promise<NodeResource[]> {
279280
return fetchClusterData<NodeResource>(store, clusterId, 'nodes', 'nodes');
280281
}
281282

282-
async function fetchNodeMetrics(store: RancherStore, clusterId: string): Promise<NodeMetric[]> {
283+
async function fetchNodeMetrics(store: Dispatchable, clusterId: string): Promise<NodeMetric[]> {
283284
return fetchClusterData<NodeMetric>(store, clusterId, 'metrics.k8s.io.nodes', 'node metrics');
284285
}
285286

286287
async function fetchClusterData<T>(
287-
store: RancherStore,
288+
store: Dispatchable,
288289
clusterId: string,
289290
resourcePath: string,
290291
label: string
@@ -305,8 +306,8 @@ async function fetchClusterData<T>(
305306
}
306307
}
307308

308-
async function fetchStorageClasses(store: RancherStore, clusterId: string): Promise<string[]> {
309-
const storageClasses = await fetchClusterData<any>(
309+
async function fetchStorageClasses(store: Dispatchable, clusterId: string): Promise<string[]> {
310+
const storageClasses = await fetchClusterData<Record<string, unknown>>(
310311
store,
311312
clusterId,
312313
'storage.k8s.io.storageclasses',

pkg/aif-ui/services/cluster-service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createErrorHandler } from '../utils/error-handler';
33
import { TIMEOUT_VALUES } from '../utils/constants';
44
import { getClusters as getReadyClusters } from './rancher-apps';
55
import type {
6-
RancherStore,
6+
Dispatchable,
77
ClusterInfo,
88
ClusterResource,
99
AppCRD,
@@ -18,15 +18,15 @@ export class ClusterService {
1818
/**
1919
* Get list of all clusters
2020
*/
21-
static async getClusters($store: RancherStore): Promise<ClusterInfo[]> {
21+
static async getClusters($store: Dispatchable): Promise<ClusterInfo[]> {
2222
return getReadyClusters($store);
2323
}
2424

2525
/**
2626
* Ensure namespace exists in cluster, create if missing
2727
*/
2828
static async ensureNamespace(
29-
$store: RancherStore,
29+
$store: Dispatchable,
3030
clusterId: string,
3131
namespace: string
3232
): Promise<void> {
@@ -62,7 +62,7 @@ export class ClusterService {
6262
* Check if app exists in cluster namespace
6363
*/
6464
static async appExists(
65-
$store: RancherStore,
65+
$store: Dispatchable,
6666
clusterId: string,
6767
namespace: string,
6868
release: string
@@ -80,7 +80,7 @@ export class ClusterService {
8080
/**
8181
* List all catalog apps in a cluster
8282
*/
83-
static async listCatalogApps($store: RancherStore, clusterId: string): Promise<AppCRD[]> {
83+
static async listCatalogApps($store: Dispatchable, clusterId: string): Promise<AppCRD[]> {
8484
try {
8585
const res = await $store.dispatch('rancher/request', {
8686
url: `/k8s/clusters/${encodeURIComponent(clusterId)}/apis/catalog.cattle.io/v1/apps?limit=2000`,

0 commit comments

Comments
 (0)