feat(ldap): support anonymous bind (no bind DN/password required)
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.
Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.
Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+149
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260707-lgh
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/favorites/icon-discovery.service.ts
|
||||||
|
- apps/api/src/favorites/icon-discovery.service.spec.ts
|
||||||
|
- apps/api/src/favorites/favorites.service.ts
|
||||||
|
- apps/api/src/favorites/favorites.controller.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- QUICK-FAV-ICON-PROXY
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Favoriten-Icons of sites that send Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai) render in the widget because the <img> is now same-origin"
|
||||||
|
- "The icon-serving endpoint only accepts a FavoriteLink id, never an arbitrary URL — no open SSRF proxy is introduced"
|
||||||
|
- "The endpoint returns the icon only for a row owned by the authenticated user; other users' rows return 404 without leaking existence"
|
||||||
|
- "Fetch failures (unreachable, timeout, non-image content-type, SSRF-blocked, no icon on record) return a real HTTP error status, not a 200 with garbage body"
|
||||||
|
- "The existing discoverFavoriteIconUrl creation-time flow is unchanged"
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/favorites/favorites.controller.ts — new GET :id/icon route"
|
||||||
|
- "apps/api/src/favorites/icon-discovery.service.ts — exported shared SSRF validation + byte-fetch method"
|
||||||
|
- "apps/api/src/favorites/favorites.service.ts — ownership-scoped icon lookup"
|
||||||
|
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx — <img src> pointing at same-origin proxy route"
|
||||||
|
key_links:
|
||||||
|
- "favorites-widget <img src> -> /api-proxy/favorites/:id/icon -> FavoritesController -> FavoritesService (userId scope) -> IconDiscoveryService (SSRF-guarded byte fetch) -> stored iconUrl"
|
||||||
|
- "shared isPublicHttpUrl / redirect-guard reused by BOTH discoverFavoriteIconUrl (HTML) and the new byte-fetch path"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Fix the Favoriten widget so icons from sites that send `Cross-Origin-Resource-Policy: same-origin` (e.g. claude.ai) render. Chrome blocks the cross-origin hotlinked `<img src={fav.iconUrl}>` with `net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`. Stop hotlinking: serve the icon bytes through the Tessera API so the `<img>` becomes same-origin.
|
||||||
|
|
||||||
|
Purpose: Restore visible favicons for CORP-protected sites without weakening SSRF posture or altering the creation-time icon-discovery flow.
|
||||||
|
Output: A new `GET /favorites/:id/icon` endpoint that streams the stored icon bytes for a row owned by the authenticated user (SSRF-guarded, size/timeout/content-type capped), and a frontend change pointing `<img src>` at that same-origin route via the existing `/api-proxy` rewrite.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@apps/api/src/favorites/favorites.controller.ts
|
||||||
|
@apps/api/src/favorites/favorites.service.ts
|
||||||
|
@apps/api/src/favorites/icon-discovery.service.ts
|
||||||
|
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
@apps/web/next.config.ts
|
||||||
|
|
||||||
|
Interface facts (already read — do not re-derive):
|
||||||
|
- Controller scopes requests via `extractContext(req)` returning `{ userId, tenantId }`; ownership on other routes (update/remove) is enforced in the service by matching `link.userId !== userId` and throwing `NotFoundException`. Match this exact pattern.
|
||||||
|
- `FavoriteLink` has `userId`, `tenantId`, `iconUrl String?`. The service's existing ownership check compares `userId` only — follow that (do NOT invent a tenantId-based check).
|
||||||
|
- `icon-discovery.service.ts` currently has module-private `isPublicHttpUrl`, `isPrivateIpAddress`, `isBlockedHostname`, and the manual-redirect loop `fetchHtml` (redirect: 'manual', per-hop re-validation, 4000ms AbortController timeout, `MAX_HTML_CHARS` cap). These are the primitives to share.
|
||||||
|
- Frontend: `<img src={fav.iconUrl}>` is at favorites-widget.tsx around lines 361-373, guarded by `{fav.iconUrl && (...)}` with an existing `onError` handler that hides the img. Client API calls use `API_URL` (favorites-api.ts) which resolves to the API origin; the browser reaches the API through the `/api-proxy/:path*` rewrite in next.config.ts.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Share SSRF validation and add an SSRF-guarded icon byte-fetch to IconDiscoveryService</name>
|
||||||
|
<files>apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/favorites/icon-discovery.service.spec.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- isPublicHttpUrl (now exported) returns false for private IPv4 (10.x, 127.x, 192.168.x, 169.254.x), blocked hostnames (localhost, .local, 0.0.0.0), and non-http(s) protocols; returns true for a public host.
|
||||||
|
- fetchIconBytes rejects when the upstream Content-Type is not an image/* type.
|
||||||
|
- fetchIconBytes rejects when the SSRF guard fails (private/blocked target).
|
||||||
|
- fetchIconBytes enforces a byte-size cap (1MB) and rejects an oversized body.
|
||||||
|
- fetchIconBytes returns { contentType, body } for a valid image response.
|
||||||
|
- discoverFavoriteIconUrl behaviour is unchanged (still returns a URL string, still falls back to origin favicon).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Refactor the module-private SSRF primitives so they are reusable by both the existing HTML-discovery path and the new byte-fetch path — do NOT duplicate the logic.
|
||||||
|
(1) Export `isPublicHttpUrl` from the module.
|
||||||
|
(2) Extract the manual-redirect fetch loop currently embedded in `fetchHtml` into a shared internal helper (e.g. `fetchWithRedirectGuard(url, { accept, timeoutMs })`) that: uses `redirect: 'manual'`, re-validates every hop with `isPublicHttpUrl`, applies an `AbortController` timeout, follows up to the existing `MAX_REDIRECTS`, and returns the final non-redirect `Response` (or null on any block/failure). Rewrite `fetchHtml` to call this helper and keep its existing HTML content-type check and `MAX_HTML_CHARS` slice — its external behaviour must not change.
|
||||||
|
(3) Add a public method `fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }>` on `IconDiscoveryService`. It parses the URL, fetches via `fetchWithRedirectGuard` with an image Accept header and a dedicated timeout constant (mirror the existing `HTML_FETCH_TIMEOUT_MS` style, e.g. `ICON_FETCH_TIMEOUT_MS`), then: verify the response `Content-Type` starts with `image/` (case-insensitive) — throw if not; read the body while enforcing a `MAX_ICON_BYTES` cap (1MB, mirroring the `MAX_HTML_CHARS` pattern) — throw if exceeded; return `{ contentType, body }`. On any failure (guard block, timeout, non-image, oversized, network error) throw a plain `Error` — do NOT return a placeholder. Leave `discoverFavoriteIconUrl` untouched.
|
||||||
|
Create the spec file exercising the behaviours listed above. Use vitest globals (config already sets `globals: true`). Stub `global.fetch` with `vi.fn()` to return controlled Response-like objects for the content-type / size / success cases; for the SSRF cases, assert `isPublicHttpUrl` directly against literal private/blocked URLs (no network). Do not make real network calls.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/favorites/icon-discovery.service.spec.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Spec passes. `isPublicHttpUrl` is exported and reused by both paths. `fetchIconBytes` exists with image-content-type check, 1MB cap, timeout, and SSRF guard. `discoverFavoriteIconUrl` signature and fallback behaviour unchanged.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add ownership-scoped icon lookup in the service and a streaming GET :id/icon endpoint</name>
|
||||||
|
<files>apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.controller.ts</files>
|
||||||
|
<action>
|
||||||
|
Service: add `async getIconBytes(id: string, userId: string): Promise<{ contentType: string; body: Buffer }>`. Load the row with `findUnique({ where: { id } })`. If `!link || link.userId !== userId` throw `NotFoundException` (identical to update/remove — do not leak existence, use the userId-only check, not tenantId). If `link.iconUrl` is null/empty, throw `NotFoundException` (no icon on record). Otherwise call `this.iconDiscovery.fetchIconBytes(link.iconUrl)`; if that throws (unreachable/timeout/non-image/SSRF-blocked), rethrow as a 502 by throwing `new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY)` (import `HttpException`, `HttpStatus` from `@nestjs/common`). Return the `{ contentType, body }`.
|
||||||
|
Controller: add `@Get(':id/icon')` handler `getIcon(@Param('id') id, @Req() req, @Res() res: Response)`. Import `Res` from `@nestjs/common` and `Response` from express (Request is already imported). Resolve `const { userId } = this.extractContext(req)`, await `this.favoritesService.getIconBytes(id, userId)`, then set `res.setHeader('Content-Type', contentType)`, set a `Cache-Control` header allowing browser caching (e.g. `public, max-age=86400`), and `res.send(body)`. Let `NotFoundException` (404) and `HttpException` 502 propagate to Nest's exception filter — do NOT catch-and-200. Do not add a placeholder image. Keep the route distinct from the existing `:id` PATCH/DELETE (this is `GET :id/icon`, no clash).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm type-check && grep -q "getIconBytes" src/favorites/favorites.service.ts && grep -q "':id/icon'" src/favorites/favorites.controller.ts && grep -q "BAD_GATEWAY" src/favorites/favorites.service.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>`GET /favorites/:id/icon` streams the stored icon bytes for a row owned by the caller with a Cache-Control header; not-owned/not-found/no-icon returns 404; upstream fetch failure returns 502. Type-check passes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Point the widget img at the same-origin proxy route</name>
|
||||||
|
<files>apps/web/src/components/dashboard/widgets/favorites-widget.tsx</files>
|
||||||
|
<action>
|
||||||
|
In `FavoriteTile`, change the icon `<img>` so its `src` targets the new same-origin proxy endpoint instead of hotlinking `fav.iconUrl`. Build the src as `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon` (consistent with how browser-side API calls reach the API through the `/api-proxy/:path*` rewrite in next.config.ts). Keep the render guard exactly as today: still only render the `<img>` when `fav.iconUrl` is truthy (that flag means "an icon URL is on record" even though the src now points at the proxy route). Keep the existing `onError` handler (hides the img, revealing the letter fallback) unchanged, plus `alt`, `width`, `height`, `loading="lazy"`, and className unchanged. Do not otherwise alter the widget.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/web && pnpm type-check && grep -q "/api-proxy/favorites/" src/components/dashboard/widgets/favorites-widget.tsx && ! grep -q "src={fav.iconUrl}" src/components/dashboard/widgets/favorites-widget.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<done>The widget's icon `<img src>` points at `/api-proxy/favorites/:id/icon`, still guarded by `fav.iconUrl` truthiness, onError fallback intact, and no longer references `src={fav.iconUrl}`. Web type-check passes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser -> API (`GET /favorites/:id/icon`) | Authenticated caller supplies a FavoriteLink id (not a URL) |
|
||||||
|
| API -> external favicon host | Server-side outbound fetch of a stored, previously-discovered URL |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-QFIP-01 | Information Disclosure (SSRF) | GET :id/icon fetch target | high | mitigate | Endpoint takes a FavoriteLink **id**, never a client-supplied URL. The URL fetched is the row's stored `iconUrl` loaded server-side — no arbitrary-URL proxy surface. All fetches go through the shared `isPublicHttpUrl` guard (private IP ranges, blocked hostnames, DNS resolution). |
|
||||||
|
| T-QFIP-02 | Information Disclosure (SSRF redirect) | fetchWithRedirectGuard | high | mitigate | Redirects handled `redirect: 'manual'` with per-hop re-validation via `isPublicHttpUrl` and a bounded `MAX_REDIRECTS` — reused from the existing HTML path, not re-implemented. |
|
||||||
|
| T-QFIP-03 | Information Disclosure (cross-user) | getIconBytes ownership check | high | mitigate | Row loaded then rejected with `NotFoundException` unless `link.userId === userId`; 404 (not 403) avoids leaking row existence — mirrors existing update/remove. |
|
||||||
|
| T-QFIP-04 | Denial of Service (resource exhaustion) | fetchIconBytes | medium | mitigate | `ICON_FETCH_TIMEOUT_MS` AbortController timeout + `MAX_ICON_BYTES` 1MB body cap prevent slow-loris and large-body memory exhaustion, mirroring existing HTML timeout/`MAX_HTML_CHARS` caps. |
|
||||||
|
| T-QFIP-05 | Tampering / Spoofing (content smuggling) | fetchIconBytes content-type gate | medium | mitigate | Response `Content-Type` must start with `image/`; non-image responses are rejected (502) rather than streamed to the browser. |
|
||||||
|
| T-QFIP-06 | Denial of Service (error masking) | controller error mapping | low | mitigate | Failures return real 404/502 statuses (never 200 + garbage); the frontend `onError` handler then cleanly reveals the letter fallback. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api exec vitest run src/favorites/icon-discovery.service.spec.ts` passes.
|
||||||
|
- `cd apps/api && pnpm type-check` passes; `cd apps/web && pnpm type-check` passes.
|
||||||
|
- Manual (optional): add a claude.ai favorite, load the dashboard, confirm the claude.ai icon now renders (network tab shows `/api-proxy/favorites/<id>/icon` returning 200 image), and a bogus/blocked target yields 404/502 with the letter fallback shown.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- CORP-protected favicons (claude.ai) render because the `<img>` is same-origin.
|
||||||
|
- No arbitrary-URL SSRF proxy exists — only id-based, ownership-scoped lookups.
|
||||||
|
- SSRF validation is shared, not duplicated; `discoverFavoriteIconUrl` is unchanged.
|
||||||
|
- Failures return 404/502; success carries a Cache-Control header.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260707-lgh-favoriten-widget-icon-proxy-fuer-cross-o/260707-lgh-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- Allow anonymous LDAP binds: bindDn/bindPassword become optional.
|
||||||
|
ALTER TABLE "LdapConfig" ALTER COLUMN "bindDn" DROP NOT NULL;
|
||||||
|
ALTER TABLE "LdapConfig" ALTER COLUMN "bindPassword" DROP NOT NULL;
|
||||||
@@ -64,8 +64,8 @@ model LdapConfig {
|
|||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
serverUrl String
|
serverUrl String
|
||||||
baseDn String
|
baseDn String
|
||||||
bindDn String
|
bindDn String?
|
||||||
bindPassword String
|
bindPassword String?
|
||||||
searchFilter String @default("(objectClass=person)")
|
searchFilter String @default("(objectClass=person)")
|
||||||
syncIntervalMin Int @default(60)
|
syncIntervalMin Int @default(60)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export class CreateLdapConfigDto {
|
|||||||
baseDn!: string;
|
baseDn!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
bindDn!: string;
|
bindDn?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
bindPassword!: string;
|
bindPassword?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class LdapController {
|
|||||||
// Never return bindPassword in API responses (T-02-17)
|
// Never return bindPassword in API responses (T-02-17)
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
bindPassword: '********',
|
bindPassword: config.bindPassword ? '********' : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export class LdapController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
bindPassword: '********',
|
bindPassword: config.bindPassword ? '********' : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export class LdapController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
bindPassword: '********',
|
bindPassword: config.bindPassword ? '********' : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +114,9 @@ export class LdapController {
|
|||||||
* Accepts optional ad-hoc serverUrl/bindDn/bindPassword so an admin can
|
* Accepts optional ad-hoc serverUrl/bindDn/bindPassword so an admin can
|
||||||
* validate connection details before ever saving a config. Any field left
|
* validate connection details before ever saving a config. Any field left
|
||||||
* out (e.g. bindPassword, which the form never re-sends once masked)
|
* out (e.g. bindPassword, which the form never re-sends once masked)
|
||||||
* falls back to the tenant's saved config. If no config is saved yet and
|
* falls back to the tenant's saved config. bindDn/bindPassword are fully
|
||||||
* the body doesn't supply all three fields, there is nothing to test.
|
* optional -- omitting both attempts an anonymous bind. Only serverUrl is
|
||||||
|
* required (directly, or from a saved config).
|
||||||
*/
|
*/
|
||||||
@Post('test-connection')
|
@Post('test-connection')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
@@ -131,9 +132,9 @@ export class LdapController {
|
|||||||
const bindDn = dto.bindDn || config?.bindDn;
|
const bindDn = dto.bindDn || config?.bindDn;
|
||||||
const bindPassword = dto.bindPassword || config?.bindPassword;
|
const bindPassword = dto.bindPassword || config?.bindPassword;
|
||||||
|
|
||||||
if (!serverUrl || !bindDn || !bindPassword) {
|
if (!serverUrl) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'serverUrl, bindDn, and bindPassword are required (either provided directly or from a saved config)',
|
'serverUrl is required (either provided directly or from a saved config)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ interface LdapConfigData {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
baseDn: string;
|
baseDn: string;
|
||||||
bindDn: string;
|
bindDn?: string | null;
|
||||||
bindPassword: string;
|
bindPassword?: string | null;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
groupFilterDns: string[];
|
groupFilterDns: string[];
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
@@ -60,19 +60,33 @@ export class LdapService {
|
|||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind a client, falling back to an anonymous bind (empty DN/password,
|
||||||
|
* per RFC 4513) when no bindDn/bindPassword is configured. Lets tenants
|
||||||
|
* connect to directories that allow anonymous read access without
|
||||||
|
* requiring a service account.
|
||||||
|
*/
|
||||||
|
private async bind(
|
||||||
|
client: Client,
|
||||||
|
bindDn?: string | null,
|
||||||
|
bindPassword?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.bind(bindDn || '', bindPassword || '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test LDAP connection with given configuration.
|
* Test LDAP connection with given configuration.
|
||||||
* Returns success/failure with optional error message.
|
* Returns success/failure with optional error message.
|
||||||
*/
|
*/
|
||||||
async testConnection(config: {
|
async testConnection(config: {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
bindDn: string;
|
bindDn?: string | null;
|
||||||
bindPassword: string;
|
bindPassword?: string | null;
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client({ url: config.serverUrl });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.bind(config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message =
|
const message =
|
||||||
@@ -96,13 +110,13 @@ export class LdapService {
|
|||||||
async listGroups(config: {
|
async listGroups(config: {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
baseDn: string;
|
baseDn: string;
|
||||||
bindDn: string;
|
bindDn?: string | null;
|
||||||
bindPassword: string;
|
bindPassword?: string | null;
|
||||||
}): Promise<LdapDirectoryEntry[]> {
|
}): Promise<LdapDirectoryEntry[]> {
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client({ url: config.serverUrl });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.bind(config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
|
|
||||||
const { searchEntries } = await client.search(config.baseDn, {
|
const { searchEntries } = await client.search(config.baseDn, {
|
||||||
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
|
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
|
||||||
@@ -167,8 +181,8 @@ export class LdapService {
|
|||||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Bind with service account
|
// 1. Bind with service account (anonymous when not configured)
|
||||||
await client.bind(config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
|
|
||||||
// 2. Build attributes list from field mappings + dn
|
// 2. Build attributes list from field mappings + dn
|
||||||
const attributes = config.fieldMappings.map((m) => m.ldapField);
|
const attributes = config.fieldMappings.map((m) => m.ldapField);
|
||||||
|
|||||||
@@ -350,9 +350,8 @@ export default function AdminLdapPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.bindDn}
|
value={formData.bindDn}
|
||||||
onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })}
|
||||||
placeholder="cn=admin,dc=example,dc=com"
|
placeholder="cn=admin,dc=example,dc=com (leer = anonymous bind)"
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -363,9 +362,8 @@ export default function AdminLdapPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={formData.bindPassword}
|
value={formData.bindPassword}
|
||||||
onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })}
|
||||||
placeholder={config ? '********' : ''}
|
placeholder={config?.bindPassword ? '********' : ''}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
required={!config}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,10 +390,7 @@ export default function AdminLdapPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
onClick={handleTestConnection}
|
||||||
disabled={
|
disabled={!config && !formData.serverUrl}
|
||||||
!config &&
|
|
||||||
!(formData.serverUrl && formData.bindDn && formData.bindPassword)
|
|
||||||
}
|
|
||||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('testConnection')}
|
{t('testConnection')}
|
||||||
|
|||||||
Reference in New Issue
Block a user