Skip to content

Commit e9010b0

Browse files
committed
feat: refactor data source management and enhance UI components for better usability
1 parent 2e1a8e7 commit e9010b0

108 files changed

Lines changed: 12229 additions & 1076 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"author": "",
1515
"license": "ISC",
1616
"dependencies": {
17+
"@elastic/elasticsearch": "^9.3.4",
1718
"@types/body-parser": "^1.19.5",
1819
"@types/express": "^5.0.2",
1920
"@types/express-ws": "^3.0.5",
@@ -22,8 +23,10 @@
2223
"drizzle-orm": "^0.45.1",
2324
"express": "^5.0.2",
2425
"express-ws": "^5.0.2",
26+
"minio": "^8.0.7",
2527
"mysql2": "^3.14.1",
2628
"nodemon": "^3.1.10",
29+
"openid-client": "^6.8.2",
2730
"pg": "^8.20.0",
2831
"ts-node": "^10.9.2",
2932
"tsx": "^4.21.0",

packages/backend/src/adapters/index.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { NextFunction, Response } from 'express'
2+
import type { AppContext } from '../app-context.ts'
3+
import { adminPermissionTargets, hasAnyPermission } from './permissions.ts'
4+
import type { AuthenticatedRequest } from './request.ts'
5+
import type { AuthPrincipal, AuthStatusPayload } from './types.ts'
6+
import { hashAuthToken } from './tokens.ts'
7+
8+
function createAnonymousPrincipal(): AuthPrincipal {
9+
return {
10+
kind: 'anonymous',
11+
user: null,
12+
roles: [],
13+
permissions: [],
14+
token: null,
15+
}
16+
}
17+
18+
function createLocalPrincipal(): AuthPrincipal {
19+
return {
20+
kind: 'local',
21+
user: null,
22+
roles: [],
23+
permissions: ['*'],
24+
token: null,
25+
}
26+
}
27+
28+
function getBearerToken(req: AuthenticatedRequest) {
29+
const authorization = req.headers.authorization
30+
if (!authorization?.startsWith('Bearer ')) {
31+
return null
32+
}
33+
34+
return authorization.slice('Bearer '.length).trim()
35+
}
36+
37+
export async function buildAuthStatus(context: AppContext): Promise<AuthStatusPayload> {
38+
return {
39+
enabled: context.config.auth.enabled,
40+
onboardingRequired: context.config.auth.enabled ? (await context.metaStore.countUsers()) === 0 : false,
41+
openIdEnabled: Boolean(context.config.auth.openId),
42+
}
43+
}
44+
45+
export async function authenticateRequest(context: AppContext, req: AuthenticatedRequest) {
46+
if (!context.config.auth.enabled) {
47+
req.auth = createLocalPrincipal()
48+
return req.auth
49+
}
50+
51+
const rawToken = getBearerToken(req)
52+
if (!rawToken) {
53+
req.auth = createAnonymousPrincipal()
54+
return req.auth
55+
}
56+
57+
const tokenRecord = await context.metaStore.getAuthTokenByHash(hashAuthToken(rawToken))
58+
if (!tokenRecord) {
59+
req.auth = createAnonymousPrincipal()
60+
return req.auth
61+
}
62+
63+
if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt).getTime() < Date.now()) {
64+
await context.metaStore.deleteAuthToken(tokenRecord.id)
65+
req.auth = createAnonymousPrincipal()
66+
return req.auth
67+
}
68+
69+
const user = await context.metaStore.getUserWithRoles(tokenRecord.userId)
70+
if (!user || user.disabled) {
71+
req.auth = createAnonymousPrincipal()
72+
return req.auth
73+
}
74+
75+
await context.metaStore.updateAuthTokenLastUsed(tokenRecord.id)
76+
77+
req.auth = {
78+
kind: tokenRecord.kind,
79+
user,
80+
roles: user.roles,
81+
permissions: Array.from(new Set([...user.permissions, ...user.roles.flatMap((role) => role.permissions)])),
82+
token: tokenRecord,
83+
}
84+
85+
return req.auth
86+
}
87+
88+
export function attachAuth(context: AppContext) {
89+
return async (req: AuthenticatedRequest, _res: Response, next: NextFunction) => {
90+
try {
91+
await authenticateRequest(context, req)
92+
next()
93+
} catch (error) {
94+
next(error)
95+
}
96+
}
97+
}
98+
99+
export function getRequestAuth(req: AuthenticatedRequest) {
100+
return req.auth ?? createAnonymousPrincipal()
101+
}
102+
103+
export function requireAuthenticated(req: AuthenticatedRequest, res: Response) {
104+
const auth = getRequestAuth(req)
105+
if (auth.kind === 'anonymous') {
106+
res.status(401).json({ error: 'Authentication required' })
107+
return false
108+
}
109+
110+
return true
111+
}
112+
113+
export function requirePermission(req: AuthenticatedRequest, res: Response, requiredPermissions: string[]) {
114+
const auth = getRequestAuth(req)
115+
if (auth.kind === 'anonymous') {
116+
res.status(401).json({ error: 'Authentication required' })
117+
return false
118+
}
119+
120+
if (!hasAnyPermission(auth.permissions, requiredPermissions)) {
121+
res.status(403).json({ error: 'Forbidden', requiredPermissions })
122+
return false
123+
}
124+
125+
return true
126+
}
127+
128+
export function requireAdminAccess(req: AuthenticatedRequest, res: Response) {
129+
return requirePermission(req, res, adminPermissionTargets('access', 'read'))
130+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as client from 'openid-client'
2+
import type { AppConfig } from '../config/app-config.ts'
3+
4+
let cachedConfiguration: client.Configuration | null = null
5+
6+
export async function getOpenIdConfiguration(config: AppConfig) {
7+
if (!config.auth.openId) {
8+
return null
9+
}
10+
11+
if (cachedConfiguration) {
12+
return cachedConfiguration
13+
}
14+
15+
cachedConfiguration = await client.discovery(
16+
new URL(config.auth.openId.issuer),
17+
config.auth.openId.clientId,
18+
config.auth.openId.clientSecret || undefined,
19+
)
20+
21+
return cachedConfiguration
22+
}
23+
24+
export {
25+
client,
26+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import crypto from 'node:crypto'
2+
3+
const SCRYPT_OPTIONS = {
4+
N: 16384,
5+
r: 8,
6+
p: 1,
7+
}
8+
9+
export function createPasswordHash(password: string) {
10+
const salt = crypto.randomBytes(16).toString('hex')
11+
const hash = crypto.scryptSync(password, salt, 64, SCRYPT_OPTIONS).toString('hex')
12+
13+
return {
14+
salt,
15+
hash,
16+
}
17+
}
18+
19+
export function verifyPasswordHash(password: string, salt: string, hash: string) {
20+
const candidate = crypto.scryptSync(password, salt, 64, SCRYPT_OPTIONS)
21+
const target = Buffer.from(hash, 'hex')
22+
23+
if (candidate.length !== target.length) {
24+
return false
25+
}
26+
27+
return crypto.timingSafeEqual(candidate, target)
28+
}
29+
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
export type PermissionAccess = 'read' | 'write'
2+
3+
function escapeRegex(value: string) {
4+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5+
}
6+
7+
function parsePermission(value: string) {
8+
const match = value.match(/^(.*?)(?::(read|write))?$/)
9+
return {
10+
path: match?.[1] ?? value,
11+
access: (match?.[2] as PermissionAccess | undefined) ?? null,
12+
}
13+
}
14+
15+
function applyPermissionAccess(target: string, access?: PermissionAccess) {
16+
if (!access || target === '*') {
17+
return parsePermission(target).path
18+
}
19+
20+
return `${parsePermission(target).path}:${access}`
21+
}
22+
23+
function withPermissionAccess(targets: string[], access?: PermissionAccess) {
24+
return Array.from(new Set(targets.map((target) => applyPermissionAccess(target, access))))
25+
}
26+
27+
export function permissionPatternMatches(pattern: string, requiredPermission: string) {
28+
if (pattern === '*') {
29+
return true
30+
}
31+
32+
const parsedPattern = parsePermission(pattern)
33+
const parsedRequired = parsePermission(requiredPermission)
34+
const regex = new RegExp(`^${escapeRegex(parsedPattern.path).replace(/\\\*/g, '.*')}$`)
35+
if (!regex.test(parsedRequired.path)) {
36+
return false
37+
}
38+
39+
if (!parsedRequired.access) {
40+
return true
41+
}
42+
43+
return !parsedPattern.access || parsedPattern.access === parsedRequired.access
44+
}
45+
46+
export function hasPermission(permissionPatterns: string[], requiredPermission: string) {
47+
return permissionPatterns.some((pattern) => permissionPatternMatches(pattern, requiredPermission))
48+
}
49+
50+
export function hasAnyPermission(permissionPatterns: string[], requiredPermissions: string[]) {
51+
return requiredPermissions.some((permission) => hasPermission(permissionPatterns, permission))
52+
}
53+
54+
export function projectPermissionTargets(
55+
projectId: string,
56+
action: 'view' | 'manage' | 'create' | 'users',
57+
access?: PermissionAccess,
58+
) {
59+
const targets = [`project.${action}`, `project.${action}.*`]
60+
61+
if (action !== 'create') {
62+
targets.push(`project.${action}.${projectId}`)
63+
}
64+
65+
return withPermissionAccess(targets, access)
66+
}
67+
68+
export function dataSourcePermissionTargets(
69+
projectId: string,
70+
sourceId: string | '*',
71+
action: 'view' | 'manage' | 'query' | 'table.edit',
72+
access?: PermissionAccess,
73+
) {
74+
return withPermissionAccess(
75+
[
76+
`datasource.${action}`,
77+
`datasource.${action}.*`,
78+
`datasource.${action}.${projectId}.*`,
79+
`datasource.${action}.${projectId}.${sourceId}`,
80+
],
81+
access,
82+
)
83+
}
84+
85+
export function adminPermissionTargets(
86+
action: 'access' | 'users' | 'roles' | 'apiKeys',
87+
access?: PermissionAccess,
88+
) {
89+
return withPermissionAccess([`admin.${action}`, 'admin.*', '*'], access)
90+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { Request } from 'express'
2+
import type { AuthPrincipal } from './types.ts'
3+
4+
export type AuthenticatedRequest = Request & {
5+
auth: AuthPrincipal
6+
}
7+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { AuthTokenRecord, RoleRecord, UserWithRolesRecord } from '../meta/types.ts'
2+
3+
export function serializeRole(role: RoleRecord) {
4+
return {
5+
id: role.id,
6+
slug: role.slug,
7+
name: role.name,
8+
description: role.description,
9+
permissions: role.permissions,
10+
createdAt: role.createdAt,
11+
updatedAt: role.updatedAt,
12+
}
13+
}
14+
15+
export function serializeUser(user: UserWithRolesRecord) {
16+
return {
17+
id: user.id,
18+
email: user.email,
19+
name: user.name,
20+
authProvider: user.authProvider,
21+
externalSubject: user.externalSubject,
22+
disabled: user.disabled,
23+
permissions: user.permissions,
24+
roleIds: user.roleIds,
25+
roles: user.roles.map((role) => serializeRole(role)),
26+
createdAt: user.createdAt,
27+
updatedAt: user.updatedAt,
28+
}
29+
}
30+
31+
export function serializeAuthToken(token: AuthTokenRecord) {
32+
return {
33+
id: token.id,
34+
userId: token.userId,
35+
kind: token.kind,
36+
name: token.name,
37+
tokenPrefix: token.tokenPrefix,
38+
storage: token.storage,
39+
expiresAt: token.expiresAt,
40+
lastUsedAt: token.lastUsedAt,
41+
createdAt: token.createdAt,
42+
}
43+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import crypto from 'node:crypto'
2+
3+
export function createRawAuthToken() {
4+
const raw = crypto.randomBytes(32).toString('base64url')
5+
const prefix = raw.slice(0, 12)
6+
7+
return {
8+
raw,
9+
prefix,
10+
hash: crypto.createHash('sha256').update(raw).digest('hex'),
11+
}
12+
}
13+
14+
export function hashAuthToken(raw: string) {
15+
return crypto.createHash('sha256').update(raw).digest('hex')
16+
}
17+
18+
export function createOpaqueStateToken() {
19+
return crypto.randomBytes(24).toString('base64url')
20+
}
21+

0 commit comments

Comments
 (0)