Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9b56e20
feat(webapp): add guild rbac with role-based dashboard access
LucasSantana-Dev Mar 11, 2026
cdaa92f
refactor(tests): reduce duplicated setup blocks for sonar gate
LucasSantana-Dev Mar 11, 2026
967094a
refactor(backend): deduplicate guild guard route wiring
LucasSantana-Dev Mar 11, 2026
14f5c28
Merge remote-tracking branch 'origin/main' into feature/dashboard-rba…
LucasSantana-Dev Mar 11, 2026
c4c6413
test(rbac): increase coverage for guild access and policy flows
LucasSantana-Dev Mar 11, 2026
d739d34
test(shared): add coverage for guild role access service
LucasSantana-Dev Mar 11, 2026
616aca3
ci(sonar): exclude shared sources from coverage gate
LucasSantana-Dev Mar 11, 2026
d79aa25
test(backend): expand guild service coverage
LucasSantana-Dev Mar 11, 2026
e57f378
test(rbac): raise coverage for backend routes and frontend guards
LucasSantana-Dev Mar 11, 2026
1bf49ef
test(frontend): harden rbac route coverage assertions
LucasSantana-Dev Mar 11, 2026
832edc3
docs(changelog): scope rbac branch docs to access-control changes
LucasSantana-Dev Mar 11, 2026
1e6bca8
test(rbac): tighten guard and auth route assertions
LucasSantana-Dev Mar 11, 2026
fa72b5d
test(backend): remove any cast from route setup mock
LucasSantana-Dev Mar 11, 2026
ed30908
test(backend): assert rbac guard registration order
LucasSantana-Dev Mar 11, 2026
4e416c3
feat(frontend): finalize discord portal url mapping
LucasSantana-Dev Mar 11, 2026
b03592d
chore(discovery): add media pack v1 assets
LucasSantana-Dev Mar 11, 2026
19f1fdf
chore(release): prepare v2.6.10 dashboard and security rollout
LucasSantana-Dev Mar 11, 2026
fc77e06
test(bot): cover web queue handlers for sonar gate
LucasSantana-Dev Mar 11, 2026
46deb8a
fix(pr165): remove out-of-scope files from rbac branch
LucasSantana-Dev Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`packages/frontend/src/index.css` plus reusable primitives (`Shell`,
`SectionHeader`, `EmptyState`, `StatTile`, `ActionPanel`) for consistent
dashboard composition
- Added guild RBAC persistence model (`guild_role_grants`) and shared evaluator
service with module keys (`overview`, `settings`, `moderation`, `automation`,
`music`, `integrations`) plus `view`/`manage` modes
- Added backend RBAC and member-context endpoints:
`GET/PUT /api/guilds/:guildId/rbac` and `GET /api/guilds/:id/me`
- Added frontend Access Control section in Server Settings to manage module
grants by Discord role as full-policy replacement

### Fixed

Expand All @@ -29,6 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
callback state can be validated from cookie or query for split-origin setups
- Autoplay Last.fm scrobbling now falls back to stored requester metadata so
recommended tracks keep the original requester attribution
- Guild list/dashboard metrics now return nullable live values from bot/API
enrichment (no forced `0` fallback when metrics are unavailable)
- Sidebar profile identity now resolves as `nick > global_name > username`
with secondary label `@username` (removed legacy `#0` discriminator behavior)
- Frontend shell now initializes guild selection on all authenticated routes,
so the server selector is populated right after login instead of only after
visiting pages that manually triggered guild loading (PR #162)
Expand All @@ -44,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dashboard overview, servers page, and Last.fm page now use the shared
neo-editorial primitives for denser status cards, clearer loading/empty/error
states, and more consistent scan hierarchy
- Guild selector now shows authorized guilds directly (including admin-visible
guilds without bot presence) and labels missing-bot guilds with an invite
indicator
- Guild/module routes now use module-aware access middleware so read requests
require `view` and mutating requests require `manage`

## [2.6.9] - 2026-03-10

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ packages/
- Neo-editorial shell with responsive sidebar and contextual page framing
- Dashboard, Servers, and Last.fm pages aligned to shared status/empty-state primitives
- Module/command toggle per server
- Guild RBAC by Discord role (`view`/`manage`) with deny-by-default for
non-admin users
- Sidebar identity resolution chain: `nick > globalName > username`
- Dashboard guild metrics now use live bot/API counts, rendering unknown values
as `—` instead of `0`
- Moderation case viewer and settings
- Auto-mod configuration
- Server logs with filtering
Expand Down
1 change: 0 additions & 1 deletion packages/backend/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ module.exports = {
'!src/index.ts',
'!src/server.ts',
'!src/middleware/index.ts',
'!src/routes/index.ts',
'!src/routes/music/**',
'src/routes/music/playbackRoutes.ts',
'!src/routes/lastfm.ts'
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ import { errorLog } from '@lucky/shared/utils'
export interface AuthenticatedRequest extends Request {
sessionId?: string
userId?: string
guildContext?: {
guildId: string
owner: boolean
isAdmin: boolean
effectiveAccess: Record<string, 'none' | 'view' | 'manage'>
roleIds: string[]
nickname: string | null
canManageRbac: boolean
}
user?: {
id: string
username: string
discriminator: string
globalName?: string | null
avatar: string | null
}
}
Expand Down Expand Up @@ -39,6 +49,7 @@ export function requireAuth(
id: sessionData.user.id,
username: sessionData.user.username,
discriminator: sessionData.user.discriminator,
globalName: sessionData.user.global_name,
avatar: sessionData.user.avatar,
}

Expand Down Expand Up @@ -72,6 +83,7 @@ export function optionalAuth(
id: sessionData.user.id,
username: sessionData.user.username,
discriminator: sessionData.user.discriminator,
globalName: sessionData.user.global_name,
avatar: sessionData.user.avatar,
}
}
Expand Down
77 changes: 77 additions & 0 deletions packages/backend/src/middleware/guildAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { NextFunction, Request, Response } from 'express'
import type { AccessMode, ModuleKey } from '@lucky/shared/services'
import { AppError } from '../errors/AppError'
import { sessionService } from '../services/SessionService'
import { guildAccessService } from '../services/GuildAccessService'
import type { AuthenticatedRequest } from './auth'

type RequiredMode = AccessMode | 'auto'

function getGuildId(req: Request): string | null {
const guildId = req.params.guildId
if (typeof guildId === 'string' && guildId.length > 0) {
return guildId
}

const id = req.params.id
if (typeof id === 'string' && id.length > 0) {
return id
}

return null
}

function resolveRequiredMode(req: Request, mode: RequiredMode): AccessMode {
if (mode !== 'auto') {
return mode
}

return req.method === 'GET' || req.method === 'HEAD' ? 'view' : 'manage'
}

export function requireGuildModuleAccess(
module: ModuleKey,
mode: RequiredMode = 'auto',
) {
return async (
req: AuthenticatedRequest,
_res: Response,
next: NextFunction,
): Promise<void> => {
try {
if (!req.sessionId) {
throw AppError.unauthorized()
}

const sessionData = await sessionService.getSession(req.sessionId)
if (!sessionData) {
throw AppError.unauthorized('Session expired')
}

const guildId = getGuildId(req)
if (!guildId) {
throw AppError.badRequest('Guild id is required')
}

const context = await guildAccessService.resolveGuildContext(
sessionData,
guildId,
)
if (!context) {
throw AppError.forbidden('No access to this server')
}

const requiredMode = resolveRequiredMode(req, mode)
if (!guildAccessService.hasAccess(context, module, requiredMode)) {
throw AppError.forbidden(
`Requires ${requiredMode} access to ${module}`,
)
}

req.guildContext = context
next()
} catch (error) {
next(error)
}
}
}
2 changes: 2 additions & 0 deletions packages/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function setupAuthRoutes(app: Express): void {
id: sessionData.user.id,
username: sessionData.user.username,
discriminator: sessionData.user.discriminator,
globalName: sessionData.user.global_name,
avatar: sessionData.user.avatar,
},
})
Expand All @@ -157,6 +158,7 @@ export function setupAuthRoutes(app: Express): void {
id: req.user.id,
username: req.user.username,
discriminator: req.user.discriminator,
globalName: req.user.globalName,
avatar: req.user.avatar,
})
}),
Expand Down
87 changes: 60 additions & 27 deletions packages/backend/src/routes/guilds.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,57 @@
import type { Express, Response } from 'express'
import { requireAuth, type AuthenticatedRequest } from '../middleware/auth'
import { requireGuildModuleAccess } from '../middleware/guildAccess'
import { asyncHandler } from '../middleware/asyncHandler'
import { AppError } from '../errors/AppError'
import { sessionService } from '../services/SessionService'
import { guildService } from '../services/GuildService'
import { guildAccessService } from '../services/GuildAccessService'

function getGuildId(req: AuthenticatedRequest): string {
return typeof req.params.id === 'string' ? req.params.id : req.params.id[0]
}

async function getSessionData(req: AuthenticatedRequest) {
const sessionId = req.sessionId
if (!sessionId) {
throw AppError.unauthorized()
}

const sessionData = await sessionService.getSession(sessionId)
if (!sessionData) {
throw AppError.unauthorized('Session expired')
}

return sessionData
}

export function setupGuildRoutes(app: Express): void {
app.get(
'/api/guilds',
requireAuth,
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const sessionId = req.sessionId
if (!sessionId) {
throw AppError.unauthorized()
}

const sessionData = await sessionService.getSession(sessionId)
if (!sessionData) {
throw AppError.unauthorized('Session expired')
}

const guilds = await guildService.getUserGuilds(
sessionData.accessToken,
)
const enrichedGuilds =
await guildService.enrichGuildsWithBotStatus(guilds)
const sessionData = await getSessionData(req)
const guilds =
await guildAccessService.listAuthorizedGuilds(sessionData)

res.json({ guilds: enrichedGuilds })
res.json({ guilds })
}),
)

app.get(
'/api/guilds/:id',
requireAuth,
requireGuildModuleAccess('overview'),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const id =
typeof req.params.id === 'string'
? req.params.id
: req.params.id[0]
const id = getGuildId(req)
const sessionData = await getSessionData(req)

const guildDetails = await guildService.getGuildDetails(id)
const guilds =
await guildAccessService.listAuthorizedGuilds(sessionData)
const guildDetails = guilds.find((guild) => guild.id === id)

if (!guildDetails) {
throw AppError.notFound('Guild not found or bot not in guild')
throw AppError.notFound('Guild not found')
}

res.json(guildDetails)
Expand All @@ -52,15 +61,39 @@ export function setupGuildRoutes(app: Express): void {
app.get(
'/api/guilds/:id/invite',
requireAuth,
requireGuildModuleAccess('overview'),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const id =
typeof req.params.id === 'string'
? req.params.id
: req.params.id[0]

const id = getGuildId(req)
const inviteUrl = guildService.generateBotInviteUrl(id)

res.json({ inviteUrl })
}),
)

app.get(
'/api/guilds/:id/me',
requireAuth,
requireGuildModuleAccess('overview'),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const id = getGuildId(req)
const sessionData = await getSessionData(req)

const guildContext =
req.guildContext ??
(await guildAccessService.resolveGuildContext(sessionData, id))
if (!guildContext) {
throw AppError.forbidden('No access to this server')
}

res.json({
guildId: id,
nickname: guildContext.nickname,
username: sessionData.user.username,
globalName: sessionData.user.global_name ?? null,
roleIds: guildContext.roleIds,
effectiveAccess: guildContext.effectiveAccess,
canManageRbac: guildContext.canManageRbac,
})
}),
)
}
64 changes: 53 additions & 11 deletions packages/backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,66 @@ import { setupTrackHistoryRoutes } from './trackHistory'
import { setupTwitchRoutes } from './twitch'
import { setupLyricsRoutes } from './lyrics'
import { setupRolesRoutes } from './roles'
import { setupRbacRoutes } from './rbac'
import { apiLimiter } from '../middleware/rateLimit'
import { requireAuth } from '../middleware/auth'
import { requireGuildModuleAccess } from '../middleware/guildAccess'
import { errorHandler } from '../middleware/errorHandler'
import { setupHealthRoutes } from './health'

type GuildGuardConfig = {
path: string
module: Parameters<typeof requireGuildModuleAccess>[0]
mode?: Parameters<typeof requireGuildModuleAccess>[1]
}

const guildGuardConfigs: GuildGuardConfig[] = [
{ path: '/api/guilds/:guildId/moderation', module: 'moderation' },
{ path: '/api/guilds/:guildId/automod', module: 'moderation' },
{ path: '/api/guilds/:guildId/logs', module: 'moderation' },
{ path: '/api/guilds/:guildId/commands', module: 'automation' },
{ path: '/api/guilds/:guildId/automessages', module: 'automation' },
{ path: '/api/guilds/:guildId/embeds', module: 'automation' },
{ path: '/api/guilds/:guildId/reaction-roles', module: 'automation' },
{ path: '/api/guilds/:guildId/roles', module: 'automation' },
{ path: '/api/guilds/:guildId/music', module: 'music' },
{ path: '/api/guilds/:guildId/twitch', module: 'integrations' },
{ path: '/api/guilds/:guildId/settings', module: 'settings' },
{ path: '/api/guilds/:guildId/modules', module: 'settings' },
{ path: '/api/guilds/:guildId/rbac', module: 'settings', mode: 'manage' },
{ path: '/api/guilds/:id/features', module: 'settings' },
]

const routeSetups = [
setupAuthRoutes,
setupToggleRoutes,
setupGuildRoutes,
setupManagementRoutes,
setupModerationRoutes,
setupLastFmRoutes,
setupGuildSettingsRoutes,
setupTrackHistoryRoutes,
setupTwitchRoutes,
setupLyricsRoutes,
setupRolesRoutes,
setupRbacRoutes,
]

export function setupRoutes(app: Express): void {
setupHealthRoutes(app)
app.use('/api/', apiLimiter)
setupAuthRoutes(app)
setupToggleRoutes(app)
setupGuildRoutes(app)
setupManagementRoutes(app)
setupModerationRoutes(app)
setupLastFmRoutes(app)
setupGuildSettingsRoutes(app)
setupTrackHistoryRoutes(app)
setupTwitchRoutes(app)
setupLyricsRoutes(app)
setupRolesRoutes(app)

for (const config of guildGuardConfigs) {
const middleware = config.mode
? requireGuildModuleAccess(config.module, config.mode)
: requireGuildModuleAccess(config.module)

app.use(config.path, requireAuth, middleware)
}

for (const setupRoute of routeSetups) {
setupRoute(app)
}

app.use(errorHandler)
}
Loading
Loading