@quazardous/qdadm 2.22.1 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth/index.ts +3 -0
- package/src/auth/oauth/GoogleOAuthAdapter.ts +18 -189
- package/src/auth/oauth/OAuthCodeAdapter.ts +268 -0
- package/src/auth/oauth/index.ts +7 -1
- package/src/components/layout/Zone.vue +17 -13
- package/src/composables/useEntityItemFormPage.ts +14 -0
- package/src/composables/useEntityItemShowPage.ts +13 -1
- package/src/composables/useListPage.ts +18 -0
- package/src/composables/usePageState.ts +76 -0
- package/src/entity/EntityManager.ts +2 -1
- package/src/entity/EntityManager.types.ts +9 -0
- package/src/entity/auth/SecurityChecker.ts +21 -4
- package/src/index.ts +2 -1
- package/src/kernel/Kernel.registries.ts +2 -1
- package/src/kernel/Kernel.routing.ts +44 -3
- package/src/kernel/Kernel.types.ts +5 -0
- package/src/kernel/Kernel.vue.ts +8 -0
- package/src/security/RolesManager.ts +22 -2
- package/src/security/explainGrant.ts +55 -0
- package/src/security/index.ts +1 -0
package/package.json
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Google sign-in for qdadm, authorization-code + PKCE (#1775).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* therefore useless to the front — it is posted to *your* backend, which
|
|
9
|
-
* exchanges it with Google using the client secret, verifies the identity, and
|
|
10
|
-
* issues **its own** session. That session token is the only thing this adapter
|
|
11
|
-
* ever stores.
|
|
12
|
-
*
|
|
13
|
-
* There is deliberately no code path that turns a Google response into a
|
|
14
|
-
* session without your backend. That is a structural constraint, not advice.
|
|
15
|
-
*
|
|
16
|
-
* ## The backend contract is open
|
|
17
|
-
*
|
|
18
|
-
* `exchangeUrl` receives a POST and returns `{ token, user }`. Nothing about it
|
|
19
|
-
* is JavaScript: implement it in Python, Go, PHP, anything. Because the
|
|
20
|
-
* contract is open, configuration is enough for the common case —
|
|
4
|
+
* `OAuthCodeAdapter` with Google's defaults: its authorization endpoint, a
|
|
5
|
+
* `/auth/google/callback` redirect, and `offlineAccess` for a refresh token.
|
|
6
|
+
* The rule and the backend contract are the generic adapter's: the browser
|
|
7
|
+
* never validates a Google credential, your backend redeems the code.
|
|
21
8
|
*
|
|
22
9
|
* ```ts
|
|
23
10
|
* new GoogleOAuthAdapter({
|
|
@@ -26,205 +13,47 @@
|
|
|
26
13
|
* })
|
|
27
14
|
* ```
|
|
28
15
|
*
|
|
29
|
-
* — and an app with a Python backend writes no adapter code at all. Override
|
|
30
|
-
* `exchange()` only when your endpoint cannot match the documented shape.
|
|
31
|
-
*
|
|
32
16
|
* See docs/auth-google.md for the wire contract and a reference backend.
|
|
33
17
|
*
|
|
34
18
|
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
35
19
|
*/
|
|
36
20
|
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
/** What the front sends your backend. */
|
|
41
|
-
export interface OAuthExchangePayload {
|
|
42
|
-
/** The authorization code the provider handed back. */
|
|
43
|
-
code: string
|
|
44
|
-
/** The PKCE verifier that proves this code belongs to this browser. */
|
|
45
|
-
codeVerifier: string
|
|
46
|
-
/** Echoed so the backend can pin the exchange to the same redirect. */
|
|
47
|
-
redirectUri: string
|
|
48
|
-
}
|
|
21
|
+
import type { AuthUser } from '../SessionAuthAdapter'
|
|
22
|
+
import { OAuthCodeAdapter, type OAuthCodeAdapterOptions } from './OAuthCodeAdapter'
|
|
49
23
|
|
|
50
|
-
export interface GoogleOAuthAdapterOptions {
|
|
51
|
-
/** OAuth client id. Not a secret — the client SECRET stays on your backend. */
|
|
52
|
-
clientId: string
|
|
53
|
-
/**
|
|
54
|
-
* Your endpoint that trades the code for a session. POSTed
|
|
55
|
-
* `OAuthExchangePayload`, expected to answer `{ token, user }`.
|
|
56
|
-
*/
|
|
57
|
-
exchangeUrl?: string
|
|
58
|
-
/**
|
|
59
|
-
* Where the provider redirects back. Must match a URI registered in the
|
|
60
|
-
* provider console **exactly** — that is the single most common setup
|
|
61
|
-
* failure. Defaults to `/auth/google/callback` on the current origin.
|
|
62
|
-
*/
|
|
63
|
-
redirectUri?: string
|
|
64
|
-
/** Scopes requested. Defaults to identity only. */
|
|
65
|
-
scopes?: string[]
|
|
24
|
+
export interface GoogleOAuthAdapterOptions extends Omit<OAuthCodeAdapterOptions, 'authorizeEndpoint'> {
|
|
66
25
|
/**
|
|
67
26
|
* Ask for a refresh token. Requires `prompt: 'consent'` on Google to be
|
|
68
27
|
* issued reliably, and the token stays with your backend — the front never
|
|
69
28
|
* sees it.
|
|
70
29
|
*/
|
|
71
30
|
offlineAccess?: boolean
|
|
72
|
-
/**
|
|
73
|
-
storageKey?: string
|
|
74
|
-
/** Authorization endpoint. Overridable for tests and for other providers. */
|
|
31
|
+
/** Authorization endpoint. Defaults to Google's; overridable for tests. */
|
|
75
32
|
authorizeEndpoint?: string
|
|
76
33
|
}
|
|
77
34
|
|
|
78
35
|
const GOOGLE_AUTHORIZE = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
79
36
|
|
|
80
37
|
export class GoogleOAuthAdapter<TUser extends AuthUser = AuthUser>
|
|
81
|
-
extends
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
protected _scopes: string[]
|
|
38
|
+
extends OAuthCodeAdapter<TUser> {
|
|
39
|
+
static override readonly label: string = 'GoogleOAuthAdapter'
|
|
40
|
+
static override readonly defaultRedirectPath: string = '/auth/google/callback'
|
|
41
|
+
|
|
86
42
|
protected _offlineAccess: boolean
|
|
87
|
-
protected _authorizeEndpoint: string
|
|
88
43
|
|
|
89
44
|
constructor(options: GoogleOAuthAdapterOptions) {
|
|
90
|
-
super({
|
|
91
|
-
|
|
92
|
-
if (!options?.clientId) {
|
|
93
|
-
throw new Error('[GoogleOAuthAdapter] clientId is required')
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
this._clientId = options.clientId
|
|
97
|
-
this._exchangeUrl = options.exchangeUrl ?? null
|
|
98
|
-
this._redirectUri =
|
|
99
|
-
options.redirectUri ??
|
|
100
|
-
(typeof window !== 'undefined'
|
|
101
|
-
? `${window.location.origin}/auth/google/callback`
|
|
102
|
-
: '/auth/google/callback')
|
|
103
|
-
this._scopes = options.scopes ?? ['openid', 'email', 'profile']
|
|
45
|
+
super({ ...options, authorizeEndpoint: options?.authorizeEndpoint ?? GOOGLE_AUTHORIZE })
|
|
104
46
|
this._offlineAccess = options.offlineAccess ?? false
|
|
105
|
-
this._authorizeEndpoint = options.authorizeEndpoint ?? GOOGLE_AUTHORIZE
|
|
106
47
|
}
|
|
107
48
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return this._redirectUri
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Build the authorization URL for a fresh attempt.
|
|
115
|
-
*
|
|
116
|
-
* Stashes the PKCE verifier, the CSRF state and where the user was heading,
|
|
117
|
-
* so the callback can finish what this started.
|
|
118
|
-
*/
|
|
119
|
-
async buildAuthorizeUrl(redirectTo = '/'): Promise<string> {
|
|
120
|
-
const { verifier, challenge, state } = await createPkceChallenge()
|
|
121
|
-
storeAttempt({ verifier, state, redirectTo })
|
|
122
|
-
|
|
123
|
-
const url = new URL(this._authorizeEndpoint)
|
|
124
|
-
url.searchParams.set('client_id', this._clientId)
|
|
125
|
-
url.searchParams.set('redirect_uri', this._redirectUri)
|
|
126
|
-
url.searchParams.set('response_type', 'code')
|
|
127
|
-
url.searchParams.set('scope', this._scopes.join(' '))
|
|
128
|
-
url.searchParams.set('state', state)
|
|
129
|
-
url.searchParams.set('code_challenge', challenge)
|
|
130
|
-
url.searchParams.set('code_challenge_method', 'S256')
|
|
49
|
+
protected override extraAuthorizeParams(): Record<string, string> {
|
|
50
|
+
const params = super.extraAuthorizeParams()
|
|
131
51
|
if (this._offlineAccess) {
|
|
132
|
-
|
|
52
|
+
params.access_type = 'offline'
|
|
133
53
|
// Google only re-issues a refresh token when consent is asked again.
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return url.toString()
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Send the browser to the provider. */
|
|
141
|
-
async beginLogin(redirectTo = '/'): Promise<void> {
|
|
142
|
-
window.location.assign(await this.buildAuthorizeUrl(redirectTo))
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Trade the code for a session **on your backend**.
|
|
147
|
-
*
|
|
148
|
-
* Override this when your endpoint's shape differs. Whatever you do, the
|
|
149
|
-
* exchange must happen server-side: the browser has no client secret and
|
|
150
|
-
* cannot verify anything Google says.
|
|
151
|
-
*/
|
|
152
|
-
async exchange(payload: OAuthExchangePayload): Promise<SessionData<TUser>> {
|
|
153
|
-
if (!this._exchangeUrl) {
|
|
154
|
-
throw new Error(
|
|
155
|
-
'[GoogleOAuthAdapter] no exchangeUrl configured, and exchange() was not overridden. ' +
|
|
156
|
-
'The authorization code must be redeemed by your backend — the browser cannot do it.'
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const response = await fetch(this._exchangeUrl, {
|
|
161
|
-
method: 'POST',
|
|
162
|
-
headers: { 'content-type': 'application/json' },
|
|
163
|
-
credentials: 'same-origin',
|
|
164
|
-
body: JSON.stringify(payload),
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
if (!response.ok) {
|
|
168
|
-
const detail = await response.text().catch(() => '')
|
|
169
|
-
throw new Error(
|
|
170
|
-
`[GoogleOAuthAdapter] exchange failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ''}`
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const data = (await response.json()) as SessionData<TUser>
|
|
175
|
-
if (!data?.token || !data?.user) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
'[GoogleOAuthAdapter] exchange endpoint must return { token, user } — see docs/auth-google.md'
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
return data
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Finish the login from the callback route's query string.
|
|
185
|
-
*
|
|
186
|
-
* @returns the session plus where to send the user next.
|
|
187
|
-
* @throws when the provider reported an error, when no attempt is pending,
|
|
188
|
-
* or when `state` does not match — each of which is a reason to refuse.
|
|
189
|
-
*/
|
|
190
|
-
async completeLogin(
|
|
191
|
-
query: URLSearchParams | Record<string, string | undefined>
|
|
192
|
-
): Promise<SessionData<TUser> & { redirectTo: string }> {
|
|
193
|
-
const read = (key: string): string | undefined =>
|
|
194
|
-
query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key]
|
|
195
|
-
|
|
196
|
-
const providerError = read('error')
|
|
197
|
-
if (providerError) {
|
|
198
|
-
throw new Error(`[GoogleOAuthAdapter] provider refused the login: ${providerError}`)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const attempt: StoredAttempt | null = takeAttempt()
|
|
202
|
-
if (!attempt) {
|
|
203
|
-
// No pending attempt: a replayed or bookmarked callback URL. Refusing is
|
|
204
|
-
// the point — the attempt is consumed on read.
|
|
205
|
-
throw new Error('[GoogleOAuthAdapter] no login attempt pending for this callback')
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const state = read('state')
|
|
209
|
-
if (!state || state !== attempt.state) {
|
|
210
|
-
throw new Error('[GoogleOAuthAdapter] state mismatch — refusing the callback')
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const code = read('code')
|
|
214
|
-
if (!code) {
|
|
215
|
-
throw new Error('[GoogleOAuthAdapter] callback carried no authorization code')
|
|
54
|
+
params.prompt = 'consent'
|
|
216
55
|
}
|
|
217
|
-
|
|
218
|
-
const session = await this.exchange({
|
|
219
|
-
code,
|
|
220
|
-
codeVerifier: attempt.verifier,
|
|
221
|
-
redirectUri: this._redirectUri,
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
this.setSession(session.token, session.user)
|
|
225
|
-
this.persist()
|
|
226
|
-
|
|
227
|
-
return { ...session, redirectTo: attempt.redirectTo || '/' }
|
|
56
|
+
return params
|
|
228
57
|
}
|
|
229
58
|
}
|
|
230
59
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in with any OAuth 2 / OIDC provider, authorization-code + PKCE (#2264).
|
|
3
|
+
*
|
|
4
|
+
* ## The one rule this class exists to enforce
|
|
5
|
+
*
|
|
6
|
+
* qdadm **never** validates a provider credential in the browser. Decoding a
|
|
7
|
+
* JWT client-side proves nothing: anyone can mint one. The authorization code
|
|
8
|
+
* is therefore useless to the front — it is posted to *your* backend, which
|
|
9
|
+
* exchanges it with the provider using the client secret, verifies the
|
|
10
|
+
* identity, and issues **its own** session. That session token is the only
|
|
11
|
+
* thing this adapter ever stores.
|
|
12
|
+
*
|
|
13
|
+
* There is deliberately no code path that turns a provider response into a
|
|
14
|
+
* session without your backend. That is a structural constraint, not advice.
|
|
15
|
+
*
|
|
16
|
+
* ## The backend contract is open
|
|
17
|
+
*
|
|
18
|
+
* `exchangeUrl` receives a POST and returns `{ token, user }`. Nothing about it
|
|
19
|
+
* is JavaScript: implement it in Python, Go, PHP, anything. Keycloak, Auth0,
|
|
20
|
+
* Okta or Entra ID only change the authorization endpoint the browser goes to:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* new OAuthCodeAdapter({
|
|
24
|
+
* clientId: 'my-admin',
|
|
25
|
+
* authorizeEndpoint: 'https://sso.example.com/realms/acme/protocol/openid-connect/auth',
|
|
26
|
+
* exchangeUrl: '/auth/oidc/exchange',
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Override `exchange()` only when your endpoint cannot match the documented
|
|
31
|
+
* shape. See docs/auth-oidc.md for the wire contract and provider examples.
|
|
32
|
+
*
|
|
33
|
+
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { LocalStorageSessionAuthAdapter, type AuthUser, type SessionData } from '../SessionAuthAdapter'
|
|
37
|
+
import { createPkceChallenge, storeAttempt, takeAttempt, type StoredAttempt } from './pkce'
|
|
38
|
+
|
|
39
|
+
/** What the front sends your backend. */
|
|
40
|
+
export interface OAuthExchangePayload {
|
|
41
|
+
/** The authorization code the provider handed back. */
|
|
42
|
+
code: string
|
|
43
|
+
/** The PKCE verifier that proves this code belongs to this browser. */
|
|
44
|
+
codeVerifier: string
|
|
45
|
+
/** Echoed so the backend can pin the exchange to the same redirect. */
|
|
46
|
+
redirectUri: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface OAuthCodeAdapterOptions {
|
|
50
|
+
/** OAuth client id. Not a secret — the client SECRET stays on your backend. */
|
|
51
|
+
clientId: string
|
|
52
|
+
/** The provider's authorization endpoint, where the browser is sent to sign in. */
|
|
53
|
+
authorizeEndpoint: string
|
|
54
|
+
/**
|
|
55
|
+
* Your endpoint that trades the code for a session. POSTed
|
|
56
|
+
* `OAuthExchangePayload`, expected to answer `{ token, user }`.
|
|
57
|
+
*/
|
|
58
|
+
exchangeUrl?: string
|
|
59
|
+
/**
|
|
60
|
+
* Where the provider redirects back. Must match a URI registered with the
|
|
61
|
+
* provider **exactly** — that is the single most common setup failure.
|
|
62
|
+
* Defaults to `/auth/callback` on the current origin.
|
|
63
|
+
*/
|
|
64
|
+
redirectUri?: string
|
|
65
|
+
/** Scopes requested. Defaults to identity only. */
|
|
66
|
+
scopes?: string[]
|
|
67
|
+
/**
|
|
68
|
+
* Extra query parameters for the authorization URL: `audience` (Auth0),
|
|
69
|
+
* `kc_idp_hint` (Keycloak), `prompt`, `login_hint`… The parameters the flow
|
|
70
|
+
* itself sets (client id, redirect, scope, state, PKCE) cannot be overridden.
|
|
71
|
+
*/
|
|
72
|
+
authorizeParams?: Record<string, string>
|
|
73
|
+
/** localStorage key for the session (see ADR 0008 on shared origins). */
|
|
74
|
+
storageKey?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parameters the flow owns: letting a caller set them would break PKCE or CSRF protection. */
|
|
78
|
+
const FLOW_PARAMS = [
|
|
79
|
+
'client_id',
|
|
80
|
+
'redirect_uri',
|
|
81
|
+
'response_type',
|
|
82
|
+
'scope',
|
|
83
|
+
'state',
|
|
84
|
+
'code_challenge',
|
|
85
|
+
'code_challenge_method',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
export class OAuthCodeAdapter<TUser extends AuthUser = AuthUser>
|
|
89
|
+
extends LocalStorageSessionAuthAdapter<TUser> {
|
|
90
|
+
/** Prefix of this adapter's error messages. Subclasses name themselves. */
|
|
91
|
+
static readonly label: string = 'OAuthCodeAdapter'
|
|
92
|
+
/** Redirect path on the current origin when `redirectUri` is not given. */
|
|
93
|
+
static readonly defaultRedirectPath: string = '/auth/callback'
|
|
94
|
+
|
|
95
|
+
protected _clientId: string
|
|
96
|
+
protected _authorizeEndpoint: string
|
|
97
|
+
protected _exchangeUrl: string | null
|
|
98
|
+
protected _redirectUri: string
|
|
99
|
+
protected _scopes: string[]
|
|
100
|
+
protected _authorizeParams: Record<string, string>
|
|
101
|
+
|
|
102
|
+
constructor(options: OAuthCodeAdapterOptions) {
|
|
103
|
+
super({ storageKey: options?.storageKey ?? 'qdadm_auth' })
|
|
104
|
+
|
|
105
|
+
if (!options?.clientId) {
|
|
106
|
+
throw new Error(`${this.errorPrefix} clientId is required`)
|
|
107
|
+
}
|
|
108
|
+
if (!options.authorizeEndpoint) {
|
|
109
|
+
throw new Error(`${this.errorPrefix} authorizeEndpoint is required: the provider's authorization URL`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this._clientId = options.clientId
|
|
113
|
+
this._authorizeEndpoint = options.authorizeEndpoint
|
|
114
|
+
this._exchangeUrl = options.exchangeUrl ?? null
|
|
115
|
+
const redirectPath = (this.constructor as typeof OAuthCodeAdapter).defaultRedirectPath
|
|
116
|
+
this._redirectUri =
|
|
117
|
+
options.redirectUri ??
|
|
118
|
+
(typeof window !== 'undefined' ? `${window.location.origin}${redirectPath}` : redirectPath)
|
|
119
|
+
this._scopes = options.scopes ?? ['openid', 'email', 'profile']
|
|
120
|
+
this._authorizeParams = { ...(options.authorizeParams ?? {}) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The redirect URI this adapter will use — handy for error messages. */
|
|
124
|
+
get redirectUri(): string {
|
|
125
|
+
return this._redirectUri
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
protected get errorPrefix(): string {
|
|
129
|
+
return `[${(this.constructor as typeof OAuthCodeAdapter).label}]`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Provider-specific query parameters for the authorization URL. Subclasses
|
|
134
|
+
* add their own here (Google's `access_type`, for instance).
|
|
135
|
+
*/
|
|
136
|
+
protected extraAuthorizeParams(): Record<string, string> {
|
|
137
|
+
return { ...this._authorizeParams }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the authorization URL for a fresh attempt.
|
|
142
|
+
*
|
|
143
|
+
* Stashes the PKCE verifier, the CSRF state and where the user was heading,
|
|
144
|
+
* so the callback can finish what this started.
|
|
145
|
+
*/
|
|
146
|
+
async buildAuthorizeUrl(redirectTo = '/'): Promise<string> {
|
|
147
|
+
const extra = this.extraAuthorizeParams()
|
|
148
|
+
const overridden = Object.keys(extra).filter((key) => FLOW_PARAMS.includes(key))
|
|
149
|
+
if (overridden.length > 0) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${this.errorPrefix} authorizeParams cannot set ${overridden.join(', ')}: the flow sets them itself`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { verifier, challenge, state } = await createPkceChallenge()
|
|
156
|
+
storeAttempt({ verifier, state, redirectTo })
|
|
157
|
+
|
|
158
|
+
const url = new URL(this._authorizeEndpoint)
|
|
159
|
+
url.searchParams.set('client_id', this._clientId)
|
|
160
|
+
url.searchParams.set('redirect_uri', this._redirectUri)
|
|
161
|
+
url.searchParams.set('response_type', 'code')
|
|
162
|
+
url.searchParams.set('scope', this._scopes.join(' '))
|
|
163
|
+
url.searchParams.set('state', state)
|
|
164
|
+
url.searchParams.set('code_challenge', challenge)
|
|
165
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
166
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
167
|
+
url.searchParams.set(key, value)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return url.toString()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Send the browser to the provider. */
|
|
174
|
+
async beginLogin(redirectTo = '/'): Promise<void> {
|
|
175
|
+
window.location.assign(await this.buildAuthorizeUrl(redirectTo))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Trade the code for a session **on your backend**.
|
|
180
|
+
*
|
|
181
|
+
* Override this when your endpoint's shape differs. Whatever you do, the
|
|
182
|
+
* exchange must happen server-side: the browser has no client secret and
|
|
183
|
+
* cannot verify anything the provider says.
|
|
184
|
+
*/
|
|
185
|
+
async exchange(payload: OAuthExchangePayload): Promise<SessionData<TUser>> {
|
|
186
|
+
if (!this._exchangeUrl) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`${this.errorPrefix} no exchangeUrl configured, and exchange() was not overridden. ` +
|
|
189
|
+
'The authorization code must be redeemed by your backend — the browser cannot do it.'
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const response = await fetch(this._exchangeUrl, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'content-type': 'application/json' },
|
|
196
|
+
credentials: 'same-origin',
|
|
197
|
+
body: JSON.stringify(payload),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const detail = await response.text().catch(() => '')
|
|
202
|
+
throw new Error(
|
|
203
|
+
`${this.errorPrefix} exchange failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ''}`
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const data = (await response.json()) as SessionData<TUser>
|
|
208
|
+
if (!data?.token || !data?.user) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`${this.errorPrefix} exchange endpoint must return { token, user } — see docs/auth-oidc.md`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
return data
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Finish the login from the callback route's query string.
|
|
218
|
+
*
|
|
219
|
+
* @returns the session plus where to send the user next.
|
|
220
|
+
* @throws when the provider reported an error, when no attempt is pending,
|
|
221
|
+
* or when `state` does not match — each of which is a reason to refuse.
|
|
222
|
+
*/
|
|
223
|
+
async completeLogin(
|
|
224
|
+
query: URLSearchParams | Record<string, string | undefined>
|
|
225
|
+
): Promise<SessionData<TUser> & { redirectTo: string }> {
|
|
226
|
+
const read = (key: string): string | undefined =>
|
|
227
|
+
query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key]
|
|
228
|
+
|
|
229
|
+
const providerError = read('error')
|
|
230
|
+
if (providerError) {
|
|
231
|
+
throw new Error(`${this.errorPrefix} provider refused the login: ${providerError}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const attempt: StoredAttempt | null = takeAttempt()
|
|
235
|
+
if (!attempt) {
|
|
236
|
+
// No pending attempt: a replayed or bookmarked callback URL. Refusing is
|
|
237
|
+
// the point — the attempt is consumed on read.
|
|
238
|
+
throw new Error(`${this.errorPrefix} no login attempt pending for this callback`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const state = read('state')
|
|
242
|
+
if (!state || state !== attempt.state) {
|
|
243
|
+
throw new Error(`${this.errorPrefix} state mismatch — refusing the callback`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const code = read('code')
|
|
247
|
+
if (!code) {
|
|
248
|
+
throw new Error(`${this.errorPrefix} callback carried no authorization code`)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const session = await this.exchange({
|
|
252
|
+
code,
|
|
253
|
+
codeVerifier: attempt.verifier,
|
|
254
|
+
redirectUri: this._redirectUri,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
this.setSession(session.token, session.user)
|
|
258
|
+
this.persist()
|
|
259
|
+
|
|
260
|
+
return { ...session, redirectTo: attempt.redirectTo || '/' }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createOAuthCodeAdapter<TUser extends AuthUser = AuthUser>(
|
|
265
|
+
options: OAuthCodeAdapterOptions
|
|
266
|
+
): OAuthCodeAdapter<TUser> {
|
|
267
|
+
return new OAuthCodeAdapter<TUser>(options)
|
|
268
|
+
}
|
package/src/auth/oauth/index.ts
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
* @module auth/oauth
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export {
|
|
10
|
+
OAuthCodeAdapter,
|
|
11
|
+
createOAuthCodeAdapter,
|
|
12
|
+
type OAuthCodeAdapterOptions,
|
|
13
|
+
type OAuthExchangePayload,
|
|
14
|
+
} from './OAuthCodeAdapter'
|
|
15
|
+
|
|
9
16
|
export {
|
|
10
17
|
GoogleOAuthAdapter,
|
|
11
18
|
createGoogleOAuthAdapter,
|
|
12
19
|
type GoogleOAuthAdapterOptions,
|
|
13
|
-
type OAuthExchangePayload,
|
|
14
20
|
} from './GoogleOAuthAdapter'
|
|
15
21
|
|
|
16
22
|
export {
|
|
@@ -155,19 +155,23 @@ function getWrappedComponent(block: BlockConfig): Component | undefined {
|
|
|
155
155
|
<div :data-zone="name" class="qdadm-zone">
|
|
156
156
|
<template v-if="hasBlocks">
|
|
157
157
|
<template v-for="block in blocks" :key="block.id || block.weight">
|
|
158
|
-
<!--
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
158
|
+
<!-- Each block in its own marker (#2363): no box (display: contents), so the layout is unchanged, and the
|
|
159
|
+
page tools can tell which block rendered what — blocks with several root elements included. -->
|
|
160
|
+
<div :data-zone-block="block.id ?? ''" style="display: contents">
|
|
161
|
+
<!-- Render wrapped blocks using memoized component -->
|
|
162
|
+
<component
|
|
163
|
+
v-if="block.wrappers"
|
|
164
|
+
:is="getWrappedComponent(block)"
|
|
165
|
+
/>
|
|
166
|
+
<!-- Render simple blocks directly, passing Zone's slot content -->
|
|
167
|
+
<component
|
|
168
|
+
v-else
|
|
169
|
+
:is="block.component"
|
|
170
|
+
v-bind="{ ...blockProps, ...block.props }"
|
|
171
|
+
>
|
|
172
|
+
<slot />
|
|
173
|
+
</component>
|
|
174
|
+
</div>
|
|
171
175
|
</template>
|
|
172
176
|
</template>
|
|
173
177
|
<component
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type CreateContext,
|
|
37
37
|
} from './useEntityItemPage'
|
|
38
38
|
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
39
|
+
import { registerPageState } from './usePageState'
|
|
39
40
|
import { deepClone } from '../utils/transformers'
|
|
40
41
|
import { getSiblingRoutes } from '../module/moduleRegistry'
|
|
41
42
|
import {
|
|
@@ -227,6 +228,19 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
227
228
|
onUnmounted(() => unregisterGuardDialog(guardDialog!))
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
// What this form is doing, for the debug tools and the MCP (#2363)
|
|
232
|
+
onUnmounted(
|
|
233
|
+
registerPageState(() => ({
|
|
234
|
+
kind: 'form',
|
|
235
|
+
entity: String((manager as { name?: unknown }).name ?? ''),
|
|
236
|
+
mode: mode.value,
|
|
237
|
+
dirtyFields: [...dirtyFields.value],
|
|
238
|
+
errors: { ...errors.value },
|
|
239
|
+
saving: saving.value,
|
|
240
|
+
loading: loading.value,
|
|
241
|
+
}))
|
|
242
|
+
)
|
|
243
|
+
|
|
230
244
|
// ============ BREADCRUMB ============
|
|
231
245
|
|
|
232
246
|
const { breadcrumbItems } = useBreadcrumb({ entity: data })
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* <ShowPage v-bind="show.props.value" v-on="show.events" />
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
|
-
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
|
36
|
+
import { ref, computed, onUnmounted, type Ref, type ComputedRef } from 'vue'
|
|
37
|
+
import { registerPageState } from './usePageState'
|
|
37
38
|
import { useRouter, type Router } from 'vue-router'
|
|
38
39
|
import { useConfirm } from 'primevue/useconfirm'
|
|
39
40
|
import { requireDeleteConfirmation } from './confirmDelete'
|
|
@@ -310,6 +311,17 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
|
|
|
310
311
|
|
|
311
312
|
const { manager, orchestrator: baseOrchestrator, data, loading, error, entityId, entityLabel, isLoaded, hydrator } = base
|
|
312
313
|
|
|
314
|
+
// What this show page is doing, for the debug tools and the MCP (#2363)
|
|
315
|
+
onUnmounted(
|
|
316
|
+
registerPageState(() => ({
|
|
317
|
+
kind: 'show',
|
|
318
|
+
entity: String((manager as { name?: unknown }).name ?? ''),
|
|
319
|
+
loaded: isLoaded.value,
|
|
320
|
+
loading: loading.value,
|
|
321
|
+
error: error.value,
|
|
322
|
+
}))
|
|
323
|
+
)
|
|
324
|
+
|
|
313
325
|
// Reload when THIS record changed outside the session (#1888 lot D).
|
|
314
326
|
// Scoped by id: an event about another record must not reload every open
|
|
315
327
|
// detail page. An event carrying no id concerns the whole entity and applies.
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../routeState'
|
|
38
38
|
import { useConfirm } from 'primevue/useconfirm'
|
|
39
39
|
import { requireDeleteConfirmation } from './confirmDelete'
|
|
40
|
+
import { registerPageState, activeFilters } from './usePageState'
|
|
40
41
|
import { useHooks } from './useHooks.js'
|
|
41
42
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
42
43
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
@@ -1184,6 +1185,23 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1184
1185
|
window.removeEventListener('resize', handleResize)
|
|
1185
1186
|
})
|
|
1186
1187
|
|
|
1188
|
+
// What this list is doing, for the debug tools and the MCP (#2363): counts and names, never row contents
|
|
1189
|
+
onUnmounted(
|
|
1190
|
+
registerPageState(() => ({
|
|
1191
|
+
kind: 'list',
|
|
1192
|
+
entity: String(entity),
|
|
1193
|
+
rows: displayItems.value.length,
|
|
1194
|
+
total: totalRecords.value,
|
|
1195
|
+
page: page.value,
|
|
1196
|
+
pageSize: pageSize.value,
|
|
1197
|
+
sort: sortField.value ? { field: sortField.value, order: sortOrder.value < 0 ? 'desc' : 'asc' } : null,
|
|
1198
|
+
search: searchQuery.value,
|
|
1199
|
+
filters: activeFilters(filterValues.value as Record<string, unknown>),
|
|
1200
|
+
selected: selected.value.length,
|
|
1201
|
+
loading: loading.value,
|
|
1202
|
+
}))
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1187
1205
|
// Reload when this entity changed outside the session (#1888 lot D).
|
|
1188
1206
|
//
|
|
1189
1207
|
// Only fires for entities the app declared in sse.entities, so an app with
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page on screen is doing (#2363), for the debug tools and the MCP's `page_snapshot`.
|
|
3
|
+
*
|
|
4
|
+
* The list, form and show composables register a getter while their page lives. Pages can nest (a list of loans
|
|
5
|
+
* inside a book's show page), so the store is a stack: the innermost page answers, and the outer one answers again
|
|
6
|
+
* once the inner one is gone. Counts and names only — never row contents.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ListPageState {
|
|
10
|
+
kind: 'list'
|
|
11
|
+
entity: string
|
|
12
|
+
rows: number
|
|
13
|
+
total: number
|
|
14
|
+
page: number
|
|
15
|
+
pageSize: number
|
|
16
|
+
sort: { field: string; order: 'asc' | 'desc' } | null
|
|
17
|
+
search: string
|
|
18
|
+
/** Active filters only: empty values are left out. */
|
|
19
|
+
filters: Record<string, unknown>
|
|
20
|
+
selected: number
|
|
21
|
+
loading: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FormPageState {
|
|
25
|
+
kind: 'form'
|
|
26
|
+
entity: string
|
|
27
|
+
mode: 'create' | 'edit'
|
|
28
|
+
dirtyFields: string[]
|
|
29
|
+
errors: Record<string, string>
|
|
30
|
+
saving: boolean
|
|
31
|
+
loading: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ShowPageState {
|
|
35
|
+
kind: 'show'
|
|
36
|
+
entity: string
|
|
37
|
+
loaded: boolean
|
|
38
|
+
loading: boolean
|
|
39
|
+
error: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PageState = ListPageState | FormPageState | ShowPageState
|
|
43
|
+
export type PageStateGetter = () => PageState
|
|
44
|
+
|
|
45
|
+
const stack: PageStateGetter[] = []
|
|
46
|
+
|
|
47
|
+
/** Register the page's state; call the returned function when the page goes away. */
|
|
48
|
+
export function registerPageState(getter: PageStateGetter): () => void {
|
|
49
|
+
stack.push(getter)
|
|
50
|
+
return () => {
|
|
51
|
+
const at = stack.lastIndexOf(getter)
|
|
52
|
+
if (at >= 0) stack.splice(at, 1)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The innermost page's state, or null when no qdadm page is on screen. */
|
|
57
|
+
export function currentPageState(): PageState | null {
|
|
58
|
+
const getter = stack[stack.length - 1]
|
|
59
|
+
if (!getter) return null
|
|
60
|
+
try {
|
|
61
|
+
return getter()
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Active filter values: what a user set, not the empty defaults. */
|
|
68
|
+
export function activeFilters(values: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
|
69
|
+
const active: Record<string, unknown> = {}
|
|
70
|
+
for (const [name, value] of Object.entries(values ?? {})) {
|
|
71
|
+
if (value === null || value === undefined || value === '') continue
|
|
72
|
+
if (Array.isArray(value) && value.length === 0) continue
|
|
73
|
+
active[name] = value
|
|
74
|
+
}
|
|
75
|
+
return active
|
|
76
|
+
}
|
|
@@ -654,7 +654,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
654
654
|
}
|
|
655
655
|
|
|
656
656
|
/**
|
|
657
|
-
*
|
|
657
|
+
* Whether this entity is one the framework provides (roles, users). Owned
|
|
658
|
+
* by qdadm — see `EntityManagerOptions.system`.
|
|
658
659
|
*/
|
|
659
660
|
get system(): boolean {
|
|
660
661
|
return this._system
|
|
@@ -271,6 +271,15 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
271
271
|
* drop the stale cache but leave the screen alone until the user asks.
|
|
272
272
|
*/
|
|
273
273
|
live?: LiveEntityPolicy
|
|
274
|
+
/**
|
|
275
|
+
* Marks an entity the framework provides (`roles`, `users`). Owned by
|
|
276
|
+
* qdadm: set by its system managers (`RolesManager`, `UsersManager`), not
|
|
277
|
+
* by applications. Behaviour may be attached to it in any release.
|
|
278
|
+
*
|
|
279
|
+
* To keep a system entity on your own storage, use its manager with your
|
|
280
|
+
* storage — `new RolesManager({ storage })` — rather than setting the flag
|
|
281
|
+
* on a plain entity.
|
|
282
|
+
*/
|
|
274
283
|
system?: boolean
|
|
275
284
|
scopeWhitelist?: string[] | null
|
|
276
285
|
isOwn?: ((record: T, user: AuthUser) => boolean) | null
|
|
@@ -26,8 +26,8 @@ export interface GrantJudgeContext {
|
|
|
26
26
|
*
|
|
27
27
|
* `install` runs while the Kernel is still being built, before modules
|
|
28
28
|
* register their entities, so `getKeys()` called here returns only the
|
|
29
|
-
* framework's own namespaces. Read the keys when you
|
|
30
|
-
*
|
|
29
|
+
* framework's own namespaces. Read the keys when you fetch (on `kernel:ready`
|
|
30
|
+
* or `auth:login`), not in `install`.
|
|
31
31
|
*/
|
|
32
32
|
permissionRegistry?: { getKeys(): string[] }
|
|
33
33
|
}
|
|
@@ -44,12 +44,29 @@ export interface GrantJudgeContext {
|
|
|
44
44
|
* built-in judgement, which stays the default.
|
|
45
45
|
* - A judge that THROWS denies — see `SecurityChecker._judge`.
|
|
46
46
|
*
|
|
47
|
-
* `isGranted` is called synchronously on every check, so answer from memory
|
|
48
|
-
*
|
|
47
|
+
* `isGranted` is called synchronously on every check, so answer from memory,
|
|
48
|
+
* and emit `security:changed` when answers change.
|
|
49
|
+
*
|
|
50
|
+
* A judge whose answers come from a request cannot have them before the first
|
|
51
|
+
* navigation: it starts inside `createApp()`. Such a judge implements `ready()`
|
|
52
|
+
* (#2412), and the route guard waits for it instead of deciding early.
|
|
49
53
|
*/
|
|
50
54
|
export interface GrantJudge {
|
|
51
55
|
isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
|
|
52
56
|
install?(ctx: GrantJudgeContext): void
|
|
57
|
+
/**
|
|
58
|
+
* Settles once the answers for the current user are loaded (#2412).
|
|
59
|
+
*
|
|
60
|
+
* Return the promise of the load in progress — the one started on
|
|
61
|
+
* `kernel:ready`, or the new one after `auth:login` — so the route guard
|
|
62
|
+
* waits for this user's answers, not an earlier user's. A resolved promise
|
|
63
|
+
* means "decide now". A rejection, or no settling within
|
|
64
|
+
* `security.readyTimeoutMs`, denies the navigation: the unknown is never
|
|
65
|
+
* allowed, only no longer decided before the answers can exist.
|
|
66
|
+
*
|
|
67
|
+
* Optional: a judge that answers from memory from the start needs none.
|
|
68
|
+
*/
|
|
69
|
+
ready?(): Promise<void>
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
/**
|
package/src/index.ts
CHANGED
|
@@ -164,8 +164,9 @@ export * from './entity/auth'
|
|
|
164
164
|
// SESSION AUTH (user authentication)
|
|
165
165
|
// ════════════════════════════════════════════════════════════════════════════
|
|
166
166
|
export { LocalStorageSessionAuthAdapter } from './auth'
|
|
167
|
-
export { GoogleOAuthAdapter, createGoogleOAuthAdapter, createPkceChallenge } from './auth'
|
|
167
|
+
export { OAuthCodeAdapter, createOAuthCodeAdapter, GoogleOAuthAdapter, createGoogleOAuthAdapter, createPkceChallenge } from './auth'
|
|
168
168
|
export type {
|
|
169
|
+
OAuthCodeAdapterOptions,
|
|
169
170
|
GoogleOAuthAdapterOptions,
|
|
170
171
|
OAuthExchangePayload,
|
|
171
172
|
PkceChallenge,
|
|
@@ -406,13 +406,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
406
406
|
this._warnUnknownKeys(
|
|
407
407
|
'security.',
|
|
408
408
|
security,
|
|
409
|
-
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
|
|
409
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant', 'readyTimeoutMs']),
|
|
410
410
|
{
|
|
411
411
|
role_hierarchy: 'roles will not inherit from one another',
|
|
412
412
|
role_permissions: 'no role will carry any permission',
|
|
413
413
|
entity_permissions: 'per-entity permissions will not be generated',
|
|
414
414
|
rolesProvider: "the current user's roles will not be resolved",
|
|
415
415
|
grant: "the application's judgement will not be consulted — the role matrix decides alone",
|
|
416
|
+
readyTimeoutMs: 'the route guard waits the default 10 s for grant.ready()',
|
|
416
417
|
}
|
|
417
418
|
)
|
|
418
419
|
}
|
|
@@ -15,6 +15,28 @@ type Self = Kernel
|
|
|
15
15
|
/**
|
|
16
16
|
* Patch Kernel prototype with routing-related methods.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* `judge.ready()`, bounded (#2412): a judge that never settles must not hold a
|
|
20
|
+
* navigation forever. A synchronous throw from `ready()` counts as a rejection.
|
|
21
|
+
*/
|
|
22
|
+
function waitForJudge(judge: { ready(): Promise<void> }, timeoutMs: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs)
|
|
25
|
+
Promise.resolve()
|
|
26
|
+
.then(() => judge.ready())
|
|
27
|
+
.then(
|
|
28
|
+
() => {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
resolve()
|
|
31
|
+
},
|
|
32
|
+
(e: unknown) => {
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
19
41
|
const proto = KernelClass.prototype
|
|
20
42
|
|
|
@@ -230,14 +252,17 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
230
252
|
// declared before its manager loads) — allow. This is the ONLY
|
|
231
253
|
// allowed pass-through; a failing access check must fail CLOSED,
|
|
232
254
|
// not silently allow (#1190).
|
|
233
|
-
|
|
255
|
+
const orchestrator = this.orchestrator
|
|
256
|
+
if (!entity || !orchestrator || !orchestrator.isRegistered(entity)) return
|
|
257
|
+
|
|
258
|
+
const checkEntity = (): { path: string } | undefined => {
|
|
234
259
|
try {
|
|
235
|
-
const manager =
|
|
260
|
+
const manager = orchestrator.get(entity)
|
|
236
261
|
if (manager && !manager.canRead()) {
|
|
237
262
|
console.warn(
|
|
238
263
|
`[qdadm] Access denied to ${to.path} (entity: ${entity})`
|
|
239
264
|
)
|
|
240
|
-
|
|
265
|
+
orchestrator.toast.error(
|
|
241
266
|
'Access Denied',
|
|
242
267
|
`You don't have permission to access ${manager.labelPlural || entity}`,
|
|
243
268
|
'Kernel'
|
|
@@ -256,7 +281,23 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
256
281
|
)
|
|
257
282
|
return { path: '/' }
|
|
258
283
|
}
|
|
284
|
+
return undefined
|
|
259
285
|
}
|
|
286
|
+
|
|
287
|
+
// A judge whose answers come from a request cannot have them yet on the
|
|
288
|
+
// first navigation, which starts inside createApp() (#2412). Its cache
|
|
289
|
+
// miss would fall through to an empty role matrix and read as a refusal:
|
|
290
|
+
// wait for its answers instead of deciding early. Still fail closed — a
|
|
291
|
+
// judge that rejects or never settles denies.
|
|
292
|
+
const judge = this.securityChecker?.grant
|
|
293
|
+
if (typeof judge?.ready !== 'function') return checkEntity()
|
|
294
|
+
const timeoutMs = this.options.security?.readyTimeoutMs ?? 10_000
|
|
295
|
+
return waitForJudge(judge as { ready(): Promise<void> }, timeoutMs).then(checkEntity, (reason: Error) => {
|
|
296
|
+
console.error(
|
|
297
|
+
`[qdadm] security.grant never became ready for ${to.path} (entity: ${entity}): ${reason.message} — denying navigation`
|
|
298
|
+
)
|
|
299
|
+
return { path: '/' }
|
|
300
|
+
})
|
|
260
301
|
})
|
|
261
302
|
}
|
|
262
303
|
|
|
@@ -105,6 +105,11 @@ export interface SecurityConfig {
|
|
|
105
105
|
* throws denies. See docs/security.md.
|
|
106
106
|
*/
|
|
107
107
|
grant?: GrantJudge
|
|
108
|
+
/**
|
|
109
|
+
* How long the route guard waits for `grant.ready()` before denying (#2412).
|
|
110
|
+
* Default 10000 ms. Only used when the judge implements `ready()`.
|
|
111
|
+
*/
|
|
112
|
+
readyTimeoutMs?: number
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/**
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { createQdadm } from '../plugin.js'
|
|
|
18
18
|
import { createNotificationStore, NOTIFICATION_KEY } from '../notifications/NotificationStore'
|
|
19
19
|
import { I18N_INJECTION_KEY } from '../i18n/useI18n'
|
|
20
20
|
import { warnWithoutQdadmVitePlugin } from './vitePluginCheck'
|
|
21
|
+
import { currentPageState } from '../composables/usePageState'
|
|
22
|
+
import { explainGrant } from '../security/explainGrant'
|
|
21
23
|
import type { Kernel } from './Kernel'
|
|
22
24
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
23
25
|
type Self = Kernel
|
|
@@ -301,6 +303,12 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
301
303
|
hooks: this.hookRegistry,
|
|
302
304
|
zones: this.zoneRegistry,
|
|
303
305
|
activeStack: this.activeStack,
|
|
306
|
+
// What the page on screen is doing — list, form or show (#2363)
|
|
307
|
+
pageState: { current: currentPageState },
|
|
308
|
+
// Why a permission is granted or denied, told the way isGranted decides it (#2363)
|
|
309
|
+
security: this.securityChecker
|
|
310
|
+
? { explain: (attribute: string, subject: unknown = null) => explainGrant(this.securityChecker!, attribute, subject) }
|
|
311
|
+
: null,
|
|
304
312
|
stackHydrator: this.stackHydrator,
|
|
305
313
|
deferred: this.deferred,
|
|
306
314
|
router: this.router,
|
|
@@ -40,6 +40,22 @@ interface EntityManagerInstance {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The `roles` system entity.
|
|
45
|
+
*
|
|
46
|
+
* It is a system entity because this manager makes it one — apps do not set
|
|
47
|
+
* `system` themselves. To keep roles behind your own API, give it your
|
|
48
|
+
* storage; it stays a system entity:
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* ctx.entity('roles', new RolesManager({ storage: myApiStorage }))
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Two things change compared with a plain entity, and both are deliberate:
|
|
55
|
+
* every `can*` goes through ONE permission (`adminPermission`), and `fields`
|
|
56
|
+
* / `idField` assume qdadm's shape (`name`, `label`, `permissions`,
|
|
57
|
+
* `inherits`) — override them when your API serves another one.
|
|
58
|
+
*/
|
|
43
59
|
export class RolesManager extends EntityManager<RoleRecord> {
|
|
44
60
|
private _rolesProvider: RoleProvider | null
|
|
45
61
|
private _permissionRegistry: PermissionRegistry | null
|
|
@@ -128,8 +144,12 @@ export class RolesManager extends EntityManager<RoleRecord> {
|
|
|
128
144
|
canDelete(item?: RoleRecord | null): boolean {
|
|
129
145
|
if (!this._isAdmin()) return false
|
|
130
146
|
if (item) {
|
|
131
|
-
// Protected system roles cannot be deleted
|
|
132
|
-
|
|
147
|
+
// Protected system roles cannot be deleted. The role code is `name` in qdadm's shape, but an
|
|
148
|
+
// app whose API keys roles differently overrides `idField` (#2406): read both, or the guard
|
|
149
|
+
// silently stops matching.
|
|
150
|
+
const record = item as unknown as Record<string, unknown>
|
|
151
|
+
const codes = [record.name, record[this.idField]]
|
|
152
|
+
return !codes.some((code) => typeof code === 'string' && RolesManager.PROTECTED_ROLES.includes(code))
|
|
133
153
|
}
|
|
134
154
|
return true
|
|
135
155
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a permission is granted or denied (#2363) — the same path as `SecurityChecker.isGranted`, told instead of
|
|
3
|
+
* answered: the app's own judge first, the role hierarchy for a `ROLE_*` attribute, then the nearest role whose
|
|
4
|
+
* grant covers the key (its exact key before a wildcard), then the user's own permissions.
|
|
5
|
+
*
|
|
6
|
+
* For the debug tools and the MCP's `page_snapshot`; it grants nothing and never changes a verdict.
|
|
7
|
+
*/
|
|
8
|
+
import { PermissionMatcher } from './PermissionMatcher'
|
|
9
|
+
import type { SecurityChecker } from '../entity/auth/SecurityChecker'
|
|
10
|
+
|
|
11
|
+
export type GrantExplanation =
|
|
12
|
+
/** No current user: everything is denied. */
|
|
13
|
+
| { granted: false; decidedBy: 'no-user' }
|
|
14
|
+
/** The app's `security.grant` function answered (a judge that throws denies). */
|
|
15
|
+
| { granted: boolean; decidedBy: 'app' }
|
|
16
|
+
/** A `ROLE_*` attribute, answered by the role hierarchy. */
|
|
17
|
+
| { granted: boolean; decidedBy: 'role-hierarchy'; roles: string[] }
|
|
18
|
+
/** A grant covers the key: `role` holds `grant` (null: the user's own permissions). */
|
|
19
|
+
| { granted: true; decidedBy: 'grant'; role: string | null; grant: string; roles: string[] }
|
|
20
|
+
/** No grant of the user's roles, nor of the user, covers the key. */
|
|
21
|
+
| { granted: false; decidedBy: 'grant'; roles: string[] }
|
|
22
|
+
|
|
23
|
+
const covering = (grants: readonly string[], attribute: string): string | undefined =>
|
|
24
|
+
grants.includes(attribute) ? attribute : grants.find((g) => PermissionMatcher.matches(g, attribute))
|
|
25
|
+
|
|
26
|
+
export function explainGrant(checker: SecurityChecker, attribute: string, subject: unknown = null): GrantExplanation {
|
|
27
|
+
const user = checker.getCurrentUser()
|
|
28
|
+
if (!user) return { granted: false, decidedBy: 'no-user' }
|
|
29
|
+
|
|
30
|
+
const judge = checker.grant
|
|
31
|
+
if (judge) {
|
|
32
|
+
try {
|
|
33
|
+
const answer: unknown = judge.isGranted(attribute, subject, user)
|
|
34
|
+
if (typeof answer === 'boolean') return { granted: answer, decidedBy: 'app' }
|
|
35
|
+
} catch {
|
|
36
|
+
return { granted: false, decidedBy: 'app' }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const roles = (user.roles || [user.role || '']).filter((role): role is string => Boolean(role))
|
|
41
|
+
if (attribute.startsWith('ROLE_')) {
|
|
42
|
+
return { granted: checker.roleHierarchy.isGrantedRole(roles, attribute), decidedBy: 'role-hierarchy', roles }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const role of roles) {
|
|
46
|
+
for (const reachable of checker.roleHierarchy.getReachableRoles(role)) {
|
|
47
|
+
const grant = covering(checker.rolesProvider.getPermissions(reachable) ?? [], attribute)
|
|
48
|
+
if (grant) return { granted: true, decidedBy: 'grant', role: reachable, grant, roles }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const own = covering(Array.isArray(user.permissions) ? (user.permissions as string[]) : [], attribute)
|
|
52
|
+
if (own) return { granted: true, decidedBy: 'grant', role: null, grant: own, roles }
|
|
53
|
+
|
|
54
|
+
return { granted: false, decidedBy: 'grant', roles }
|
|
55
|
+
}
|