Skip to content

Commit ba3a4e1

Browse files
committed
Implement frontend checkAccess
1 parent dc90556 commit ba3a4e1

File tree

16 files changed

+308
-64
lines changed

16 files changed

+308
-64
lines changed

packages/sprinkle-account/app/assets/composables/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { useRegisterApi } from './useRegisterApi'
44
export { useUserProfileEditApi } from './useUserProfileEditApi'
55
export { useUserPasswordEditApi } from './useUserPasswordEditApi'
66
export { useUserEmailEditApi } from './useUserEmailEditApi'
7+
export { useAuthorizationManager } from './useAuthorizationManager'
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useConfigStore } from '@userfrosting/sprinkle-core/stores'
2+
import type { UserDataInterface } from '../interfaces'
3+
4+
/**
5+
* API Composable
6+
*/
7+
export function useAuthorizationManager(user: UserDataInterface | null) {
8+
function checkAccess(slug: string): Boolean {
9+
// Trace debug information
10+
debugAuth(`==> Checking authorization access for ${user?.user_name}`, {
11+
user,
12+
slug
13+
})
14+
15+
// Deny access if no user is defined.
16+
if (user === null) {
17+
debugAuth('No user defined. Access denied.')
18+
19+
return false
20+
}
21+
22+
// The master (root) account has access to everything.
23+
if (user.is_master) {
24+
debugAuth('User is the master (root) user. Access granted.')
25+
26+
return true
27+
}
28+
29+
// Find all permissions that apply to this user (via roles), and check if any evaluate to true.
30+
if (user.permissions === null || user.permissions[slug] === undefined) {
31+
debugAuth('No permissions found. Access denied.')
32+
33+
return false
34+
}
35+
36+
// Find matching permission conditions
37+
const conditions = user.permissions[slug]
38+
debugAuth('Found matching permission conditions', conditions)
39+
40+
// Check if any condition is 'always()'
41+
if (conditions.some((condition) => condition === 'always()')) {
42+
debugAuth(`User passed conditions 'always()'. Access granted.`)
43+
44+
return true
45+
} else if (conditions.length !== 0) {
46+
// We don't support other condition than always(). Log a warning and don't accept.
47+
debugAuth(`Unsupported conditions found. Only 'always()' is supported.`)
48+
}
49+
50+
debugAuth('User failed to pass any of the matched permissions. Access denied.')
51+
52+
return false
53+
}
54+
55+
function debugAuth(message: string, payload?: any): void {
56+
const config = useConfigStore()
57+
if (config.get('site.debug.auth', false)) {
58+
console.debug(message, payload)
59+
}
60+
}
61+
62+
return {
63+
checkAccess
64+
}
65+
}
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { UserInterface, UserPermissionsMapInterface } from './'
1+
import type { UserDataInterface } from './'
22

33
/**
44
* API Interfaces - What the API expects and what it returns
@@ -11,7 +11,4 @@ import type { UserInterface, UserPermissionsMapInterface } from './'
1111
*
1212
* This api doesn't have a corresponding Request data interface.
1313
*/
14-
export interface AuthCheckResponse {
15-
user: UserInterface | null
16-
permissions: UserPermissionsMapInterface | null
17-
}
14+
export interface AuthCheckResponse extends UserDataInterface {}

packages/sprinkle-account/app/assets/interfaces/LoginApi.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { UserInterface, UserPermissionsMapInterface } from './'
1+
import type { UserDataInterface } from './'
22

33
/**
44
* API Interfaces - What the API expects and what it returns
@@ -12,8 +12,7 @@ export interface LoginRequest {
1212
}
1313

1414
export interface LoginResponse {
15-
user: UserInterface
16-
permissions: UserPermissionsMapInterface
15+
user: UserDataInterface
1716
message: string
1817
redirect: string
1918
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { UserInterface, UserPermissionsMapInterface } from './'
2+
3+
/**
4+
* Special user interface that includes permissions and master status.
5+
* This interface is used by the auth store to store the current user with extra
6+
* information needed for authorization.
7+
*/
8+
export interface UserDataInterface extends UserInterface {
9+
permissions: UserPermissionsMapInterface
10+
is_master: boolean
11+
}

packages/sprinkle-account/app/assets/interfaces/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export type { PasswordEditRequest } from './PasswordEditApi'
99
export type { EmailEditRequest } from './EmailEditApi'
1010
export type { RegisterRequest, RegisterResponse } from './RegisterApi'
1111
export type { LoginRequest, LoginResponse } from './LoginApi'
12+
export type { UserDataInterface } from './UserDataInterface'

packages/sprinkle-account/app/assets/stores/auth.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,43 @@
11
import { defineStore } from 'pinia'
22
import axios from 'axios'
33
import type {
4-
UserInterface,
54
LoginRequest,
65
LoginResponse,
76
AuthCheckResponse,
8-
UserPermissionsMapInterface
7+
UserDataInterface
98
} from '../interfaces'
109
import { type AlertInterface, Severity } from '@userfrosting/sprinkle-core/interfaces'
1110
import { useTranslator } from '@userfrosting/sprinkle-core/stores'
11+
import { useAuthorizationManager } from '../composables/useAuthorizationManager'
1212

1313
export const useAuthStore = defineStore('auth', {
1414
persist: true,
1515
state: () => {
1616
return {
17-
user: null as UserInterface | null,
18-
permissions: null as UserPermissionsMapInterface | null
17+
user: null as UserDataInterface | null
1918
}
2019
},
2120
getters: {
22-
isAuthenticated: (state): boolean => state.user !== null
21+
isAuthenticated: (state): boolean => state.user !== null,
22+
checkAccess:
23+
(state) =>
24+
(slug: string): Boolean => {
25+
const authorizer = useAuthorizationManager(state.user)
26+
return authorizer.checkAccess(slug)
27+
}
2328
},
2429
actions: {
25-
setUser(user: UserInterface, permissions: UserPermissionsMapInterface): void {
30+
setUser(user: UserDataInterface): void {
2631
this.user = user
27-
this.permissions = permissions
2832
},
2933
unsetUser(): void {
3034
this.user = null
31-
this.permissions = null
3235
},
3336
async login(form: LoginRequest) {
3437
return axios
3538
.post<LoginResponse>('/account/login', form)
3639
.then((response) => {
37-
this.setUser(response.data.user, response.data.permissions)
40+
this.setUser(response.data.user)
3841

3942
// Reload the translator dictionary to reflect the user's language
4043
useTranslator().load()
@@ -58,27 +61,26 @@ export const useAuthStore = defineStore('auth', {
5861
return axios
5962
.get<AuthCheckResponse>('/account/auth-check')
6063
.then((response) => {
61-
if (response.data.user === null) {
62-
this.unsetUser()
63-
} else {
64-
this.setUser(response.data.user, response.data.permissions ?? {})
65-
}
64+
this.setUser(response.data)
6665

6766
return this.user
6867
})
6968
.catch((err) => {
70-
this.unsetUser()
69+
// Test status is 401 and unset user, otherwise, throw error
70+
if (err.response.status === 401) {
71+
this.unsetUser()
72+
} else {
73+
const error: AlertInterface = {
74+
...{
75+
description: 'An error as occurred',
76+
style: Severity.Danger,
77+
closeBtn: true
78+
},
79+
...err.response.data
80+
}
7181

72-
const error: AlertInterface = {
73-
...{
74-
description: 'An error as occurred',
75-
style: Severity.Danger,
76-
closeBtn: true
77-
},
78-
...err.response.data
82+
throw error
7983
}
80-
81-
throw error
8284
})
8385
},
8486
async logout() {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { useConfigStore } from '@userfrosting/sprinkle-core/stores'
3+
import { useAuthorizationManager } from '../../composables'
4+
import type { UserDataInterface } from 'app/assets/interfaces'
5+
6+
// Mock the user and it's permissions
7+
const mockUser: UserDataInterface = {
8+
id: 1,
9+
user_name: 'test_user',
10+
first_name: 'Test',
11+
last_name: 'User',
12+
full_name: 'Test User',
13+
email: 'test_user@example.com',
14+
avatar: '',
15+
flag_enabled: true,
16+
flag_verified: true,
17+
group_id: null,
18+
locale: 'en_US',
19+
created_at: new Date().toString(),
20+
updated_at: new Date().toString(),
21+
deleted_at: null,
22+
is_master: false,
23+
permissions: {
24+
'test.permission': ['always()'],
25+
'unsupported.permission': ['unsupportedCondition()'],
26+
'multiple.conditions': ['always()', 'unsupportedCondition()']
27+
}
28+
}
29+
30+
// Mock the config store
31+
vi.mock('@userfrosting/sprinkle-core/stores')
32+
const mockUseConfigStore = {
33+
get: vi.fn()
34+
}
35+
36+
describe('useAuthorizationManager', () => {
37+
afterEach(() => {
38+
vi.clearAllMocks()
39+
vi.resetAllMocks()
40+
})
41+
42+
beforeEach(() => {
43+
// Mock the config store
44+
mockUseConfigStore.get.mockReturnValue(true)
45+
vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
46+
47+
// Mock console.debug
48+
vi.spyOn(console, 'debug').mockImplementation(() => {})
49+
})
50+
51+
test('should deny access if no user is defined', () => {
52+
const { checkAccess } = useAuthorizationManager(null)
53+
expect(checkAccess('test.permission')).toBe(false)
54+
expect(console.debug).toHaveBeenCalledWith('No user defined. Access denied.', undefined)
55+
})
56+
57+
test('should deny access if no permissions are defined', () => {
58+
// Force mock user permissions to be empty
59+
const mockUserWithNoPermissions = { ...mockUser, permissions: {} }
60+
const { checkAccess } = useAuthorizationManager(mockUserWithNoPermissions)
61+
expect(checkAccess('test.permission')).toBe(false)
62+
})
63+
64+
test('should deny access if the permission slug is not found', () => {
65+
const { checkAccess } = useAuthorizationManager(mockUser)
66+
expect(checkAccess('nonexistent.permission')).toBe(false)
67+
})
68+
69+
test('should grant access if the condition is "always()"', () => {
70+
const { checkAccess } = useAuthorizationManager(mockUser)
71+
expect(checkAccess('test.permission')).toBe(true)
72+
})
73+
74+
test('should grant access if the user is the master user', () => {
75+
// Force mock user permissions to be empty
76+
const mockUserIsMaster = { ...mockUser, is_master: true }
77+
const { checkAccess } = useAuthorizationManager(mockUserIsMaster)
78+
79+
// Accept even if the condition is unsupported or doesn't exist
80+
expect(checkAccess('test.permission')).toBe(true)
81+
expect(checkAccess('unsupported.permission')).toBe(true)
82+
expect(checkAccess('exist.not')).toBe(true)
83+
})
84+
85+
test('should deny access if the condition is unsupported', () => {
86+
const { checkAccess } = useAuthorizationManager(mockUser)
87+
expect(checkAccess('unsupported.permission')).toBe(false)
88+
})
89+
90+
test('should grant access if conditions contains one "always()"', () => {
91+
const { checkAccess } = useAuthorizationManager(mockUser)
92+
expect(checkAccess('multiple.conditions')).toBe(true)
93+
})
94+
95+
test('should deny access if no conditions are met', () => {
96+
const { checkAccess } = useAuthorizationManager(mockUser)
97+
expect(checkAccess('exist.not')).toBe(false)
98+
})
99+
100+
test('should not send debug to console if config disabled', () => {
101+
// Disable debug for this test
102+
mockUseConfigStore.get.mockReturnValue(false)
103+
vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
104+
105+
const { checkAccess } = useAuthorizationManager(null)
106+
expect(checkAccess('test.permission')).toBe(false)
107+
expect(console.debug).not.toHaveBeenCalled()
108+
})
109+
})

0 commit comments

Comments
 (0)