diff --git a/backend/src/authentication/providers/ldap/auth-ldap.config.ts b/backend/src/authentication/providers/ldap/auth-ldap.config.ts index 6a840aaa..9428ba8f 100644 --- a/backend/src/authentication/providers/ldap/auth-ldap.config.ts +++ b/backend/src/authentication/providers/ldap/auth-ldap.config.ts @@ -76,6 +76,14 @@ export class AuthProviderLDAPConfig { @IsString() netbiosName?: string + @IsOptional() + @IsString() + serviceBindDN?: string + + @IsOptional() + @IsString() + serviceBindPassword?: string + @IsDefined() @IsNotEmptyObject() @IsObject() diff --git a/backend/src/authentication/providers/ldap/auth-ldap.constants.ts b/backend/src/authentication/providers/ldap/auth-ldap.constants.ts index 112e1041..1a149f02 100644 --- a/backend/src/authentication/providers/ldap/auth-ldap.constants.ts +++ b/backend/src/authentication/providers/ldap/auth-ldap.constants.ts @@ -15,4 +15,12 @@ export const LDAP_COMMON_ATTR = { MEMBER_OF: 'memberOf' } as const +export const LDAP_SEARCH_ATTR = { + BASE: 'base', + SUB: 'sub', + GROUP_OF_NAMES: 'groupOfNames', + OBJECT_CLASS: 'objectClass', + MEMBER: 'member' +} as const + export const ALL_LDAP_ATTRIBUTES = [...Object.values(LDAP_LOGIN_ATTR), ...Object.values(LDAP_COMMON_ATTR)] diff --git a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts index a3cadafe..453dd4a3 100644 --- a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts +++ b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts @@ -76,6 +76,7 @@ describe(AuthProviderLDAP.name, () => { configuration.auth.ldap = next ;(authProviderLDAP as any).ldapConfig = next ;(authProviderLDAP as any).isAD = [LDAP_LOGIN_ATTR.SAM, LDAP_LOGIN_ATTR.UPN].includes(next.attributes.login) + ;(authProviderLDAP as any).hasServiceBind = Boolean(next.serviceBindDN && next.serviceBindPassword) } const mockBindResolve = () => { @@ -306,6 +307,120 @@ describe(AuthProviderLDAP.name, () => { expect(usersManager.updateAccesses).toHaveBeenCalledWith(createdUser, '192.168.1.10', true) }) + it('should accept adminGroup as full DN', async () => { + setLdapConfig({ + options: { + adminGroup: 'CN=Admins,OU=Groups,DC=example,DC=org' + } + }) + usersManager.findUser.mockResolvedValue(null) + mockBindResolve() + mockSearchEntries([ + { + uid: 'john', + givenName: 'John', + sn: 'Doe', + mail: 'john@example.org', + memberOf: ['CN=Admins,OU=Groups,DC=example,DC=org'] + } + ]) + const createdUser: any = { id: 9, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() } + adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser) + usersManager.fromUserId.mockResolvedValue(createdUser) + + const res = await authProviderLDAP.validateUser('john', 'pwd') + + expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith( + expect.objectContaining({ role: USER_ROLE.ADMINISTRATOR }), + USER_ROLE.ADMINISTRATOR + ) + expect(res).toBe(createdUser) + }) + + it('should use groupOfNames to detect admin membership when memberOf is missing', async () => { + setLdapConfig({ options: { adminGroup: 'Admins' } }) + usersManager.findUser.mockResolvedValue(null) + mockBindResolve() + ldapClient.search + .mockResolvedValueOnce({ + searchEntries: [ + { + uid: 'john', + cn: 'John Doe', + mail: 'john@example.org', + dn: 'uid=john,ou=people,dc=example,dc=org' + } + ] + }) + .mockResolvedValueOnce({ searchEntries: [{ cn: 'Admins' }] }) + const createdUser: any = { id: 3, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() } + adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser) + usersManager.fromUserId.mockResolvedValue(createdUser) + + const res = await authProviderLDAP.validateUser('john', 'pwd') + + expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith( + expect.objectContaining({ role: USER_ROLE.ADMINISTRATOR }), + USER_ROLE.ADMINISTRATOR + ) + expect(res).toBe(createdUser) + }) + + it('should use service bind for LDAP searches when configured', async () => { + setLdapConfig({ + serviceBindDN: 'cn=svc,dc=example,dc=org', + serviceBindPassword: 'secret' + }) + usersManager.findUser.mockResolvedValue(null) + mockBindResolve() + ldapClient.search.mockResolvedValueOnce({ + searchEntries: [{ uid: 'john', cn: 'John Doe', mail: 'john@example.org', dn: 'uid=john,ou=people,dc=example,dc=org' }] + }) + const createdUser: any = { id: 8, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() } + adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser) + usersManager.fromUserId.mockResolvedValue(createdUser) + + await authProviderLDAP.validateUser('john', 'pwd') + + expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret') + expect(ldapClient.bind).toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd') + }) + + it('should return null when service bind is set but user DN is not found', async () => { + setLdapConfig({ + serviceBindDN: 'cn=svc,dc=example,dc=org', + serviceBindPassword: 'secret' + }) + usersManager.findUser.mockResolvedValue(null) + mockBindResolve() + ldapClient.search.mockResolvedValueOnce({ searchEntries: [] }) + + const res = await authProviderLDAP.validateUser('john', 'pwd') + + expect(res).toBeNull() + expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret') + expect(ldapClient.bind).not.toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd') + }) + + it('should return null when user bind fails after service bind', async () => { + setLdapConfig({ + serviceBindDN: 'cn=svc,dc=example,dc=org', + serviceBindPassword: 'secret' + }) + usersManager.findUser.mockResolvedValue(null) + ldapClient.unbind.mockResolvedValue(undefined) + ldapClient.bind.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new InvalidCredentialsError('invalid credentials')) + ldapClient.search.mockResolvedValueOnce({ + searchEntries: [{ dn: 'uid=john,ou=people,dc=example,dc=org', cn: 'John Doe' }] + }) + + const res = await authProviderLDAP.validateUser('john', 'pwd') + + expect(res).toBeNull() + expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret') + expect(ldapClient.bind).toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd') + }) + it('should keep admin role when adminGroup is not configured', async () => { setLdapConfig({ options: { adminGroup: undefined } }) const existingUser: any = buildUser({ id: 5, role: USER_ROLE.ADMINISTRATOR }) @@ -388,7 +503,7 @@ describe(AuthProviderLDAP.name, () => { expect(normalized.uid).toBe('john') expect(normalized.mail).toBe('john@example.org') - expect(normalized.memberOf).toEqual(['Admins', 'Staff']) + expect(normalized.memberOf).toEqual(['CN=Admins,OU=Groups,DC=example,DC=org', 'Admins', 'CN=Staff,OU=Groups,DC=example,DC=org', 'Staff']) }) it('should build LDAP logins for SAM account name when netbiosName is set', () => { diff --git a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts index 69f7426d..0df3fe5d 100644 --- a/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts +++ b/backend/src/authentication/providers/ldap/auth-provider-ldap.service.ts @@ -11,14 +11,18 @@ import { configuration } from '../../../configuration/config.environment' import type { AUTH_SCOPE } from '../../constants/scope' import { AuthProvider } from '../auth-providers.models' import type { AuthProviderLDAPConfig } from './auth-ldap.config' -import { ALL_LDAP_ATTRIBUTES, LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './auth-ldap.constants' +import { ALL_LDAP_ATTRIBUTES, LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR, LDAP_SEARCH_ATTR } from './auth-ldap.constants' -type LdapUserEntry = Entry & Record +type LdapUserEntry = Entry & + Record, string> & { + [LDAP_COMMON_ATTR.MEMBER_OF]?: string[] + } @Injectable() export class AuthProviderLDAP implements AuthProvider { private readonly logger = new Logger(AuthProviderLDAP.name) private readonly ldapConfig: AuthProviderLDAPConfig = configuration.auth.ldap + private readonly hasServiceBind = Boolean(this.ldapConfig.serviceBindDN && this.ldapConfig.serviceBindPassword) private readonly isAD = this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.SAM || this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.UPN private clientOptions: ClientOptions = { timeout: 6000, connectTimeout: 6000, url: '' } @@ -27,9 +31,10 @@ export class AuthProviderLDAP implements AuthProvider { private readonly adminUsersManager: AdminUsersManager ) {} - async validateUser(login: string, password: string, ip?: string, scope?: AUTH_SCOPE): Promise { + async validateUser(loginOrEmail: string, password: string, ip?: string, scope?: AUTH_SCOPE): Promise { + // Authenticate user via LDAP and sync local user state. // Find user from his login or email - let user: UserModel = await this.usersManager.findUser(this.dbLogin(login), false) + let user: UserModel = await this.usersManager.findUser(this.dbLogin(loginOrEmail), false) if (user) { if (user.isGuest || scope) { // Allow local password authentication for guest users and application scopes (app passwords) @@ -44,7 +49,7 @@ export class AuthProviderLDAP implements AuthProvider { let entry: false | LdapUserEntry = false try { // If a user was found, use the stored login. This allows logging in with an email. - entry = await this.checkAuth(user?.login || login, password) + entry = await this.checkAuth(user?.login || loginOrEmail, password) } catch (e) { ldapErrorMessage = e.message } @@ -55,7 +60,7 @@ export class AuthProviderLDAP implements AuthProvider { // Allow local password authentication for: // - admin users (break-glass access) // - regular users when password authentication fallback is enabled - if (user && Boolean(ldapErrorMessage) && (user.isAdmin || this.ldapConfig.options.enablePasswordAuthFallback)) { + if (user && (user.isAdmin || (Boolean(ldapErrorMessage) && this.ldapConfig.options.enablePasswordAuthFallback))) { const localUser = await this.usersManager.logUser(user, password, ip) if (localUser) return localUser } @@ -86,27 +91,34 @@ export class AuthProviderLDAP implements AuthProvider { } private async checkAuth(login: string, password: string): Promise { + // Bind and fetch LDAP entry, optionally via service account. const ldapLogin = this.buildLdapLogin(login) // AD: bind directly with the user input (UPN or DOMAIN\user) // Generic LDAP: build DN from login attribute + baseDN - const bindUserDN = this.isAD ? ldapLogin : `${this.ldapConfig.attributes.login}=${ldapLogin},${this.ldapConfig.baseDN}` - let client: Client + const bindUserDN = this.buildBindUserDN(ldapLogin) let error: InvalidCredentialsError | any for (const s of this.ldapConfig.servers) { - client = new Client({ ...this.clientOptions, url: s }) + const client = new Client({ ...this.clientOptions, url: s }) + let attemptedBindDN = bindUserDN try { - await client.bind(bindUserDN, password) - return await this.checkAccess(ldapLogin, client) - } catch (e) { - if (e.errors?.length) { - for (const err of e.errors) { - this.logger.warn(`${this.checkAuth.name} - ${bindUserDN} : ${err}`) - error = err + if (this.hasServiceBind) { + attemptedBindDN = this.ldapConfig.serviceBindDN + await client.bind(this.ldapConfig.serviceBindDN, this.ldapConfig.serviceBindPassword) + const result = await this.findUserEntry(ldapLogin, client) + if (!result || !result.userDn) { + this.logger.warn(`${this.checkAuth.name} - no LDAP entry found for : ${login}`) + return false } - } else { - error = e - this.logger.warn(`${this.checkAuth.name} - ${bindUserDN} : ${e}`) + const { entry, userDn } = result + attemptedBindDN = userDn + await client.bind(userDn, password) + return entry } + attemptedBindDN = bindUserDN + await client.bind(bindUserDN, password) + return await this.checkAccess(ldapLogin, client, bindUserDN) + } catch (e) { + error = this.handleBindError(e, attemptedBindDN) if (error instanceof InvalidCredentialsError) { return false } @@ -123,35 +135,53 @@ export class AuthProviderLDAP implements AuthProvider { return false } - private async checkAccess(login: string, client: Client): Promise { + private async checkAccess(login: string, client: Client, bindUserDN?: string): Promise { + // Search for the LDAP entry and normalize attributes. + const result = await this.findUserEntry(login, client, bindUserDN) + return result ? result.entry : false + } + + private async findUserEntry(login: string, client: Client, bindUserDN?: string): Promise<{ entry: LdapUserEntry; userDn?: string } | false> { const searchFilter = this.buildUserFilter(login, this.ldapConfig.filter) try { const { searchEntries } = await client.search(this.ldapConfig.baseDN, { - scope: 'sub', + scope: LDAP_SEARCH_ATTR.SUB, filter: searchFilter, attributes: ALL_LDAP_ATTRIBUTES }) if (searchEntries.length === 0) { - this.logger.debug(`${this.checkAccess.name} - search filter : ${searchFilter}`) - this.logger.warn(`${this.checkAccess.name} - no LDAP entry found for : ${login}`) + this.logger.debug(`${this.findUserEntry.name} - search filter : ${searchFilter}`) + this.logger.warn(`${this.findUserEntry.name} - no LDAP entry found for : ${login}`) return false } if (searchEntries.length > 1) { - this.logger.warn(`${this.checkAccess.name} - multiple LDAP entries found for : ${login}, using first one`) + this.logger.warn(`${this.findUserEntry.name} - multiple LDAP entries found for : ${login}, using first one`) } - // Always return the first valid entry - return this.convertToLdapUserEntry(searchEntries[0]) + const rawEntry = searchEntries[0] + const entry: LdapUserEntry = this.convertToLdapUserEntry(rawEntry) + const userDn = (rawEntry as { dn?: string }).dn || bindUserDN + + if (this.ldapConfig.options.adminGroup && !this.hasAdminGroup(entry, this.ldapConfig.options.adminGroup)) { + if (userDn && (await this.isMemberOfGroupOfNames(this.ldapConfig.options.adminGroup, userDn, client))) { + const existing = Array.isArray(entry[LDAP_COMMON_ATTR.MEMBER_OF]) ? entry[LDAP_COMMON_ATTR.MEMBER_OF] : [] + entry[LDAP_COMMON_ATTR.MEMBER_OF] = [...new Set([...existing, this.ldapConfig.options.adminGroup])] + } + } + + // Return the first matching entry. + return { entry, userDn } } catch (e) { - this.logger.debug(`${this.checkAccess.name} - search filter : ${searchFilter}`) - this.logger.error(`${this.checkAccess.name} - ${login} : ${e}`) + this.logger.debug(`${this.findUserEntry.name} - search filter : ${searchFilter}`) + this.logger.error(`${this.findUserEntry.name} - ${login} : ${e}`) return false } } private async updateOrCreateUser(identity: CreateUserDto, user: UserModel): Promise { + // Create or update the local user record from LDAP identity. if (user === null) { // Create identity.permissions = this.ldapConfig.options.autoCreatePermissions.join(',') @@ -215,12 +245,21 @@ export class AuthProviderLDAP implements AuthProvider { } private convertToLdapUserEntry(entry: Entry): LdapUserEntry { + // Normalize memberOf and other LDAP attributes for downstream usage. for (const attr of ALL_LDAP_ATTRIBUTES) { if (attr === LDAP_COMMON_ATTR.MEMBER_OF && entry[attr]) { - entry[attr] = (Array.isArray(entry[attr]) ? entry[attr] : entry[attr] ? [entry[attr]] : []) - .filter((v: any) => typeof v === 'string') - .map((v) => v.match(/cn\s*=\s*([^,]+)/i)?.[1]?.trim()) - .filter(Boolean) + const values = (Array.isArray(entry[attr]) ? entry[attr] : entry[attr] ? [entry[attr]] : []).filter( + (v: any) => typeof v === 'string' + ) as string[] + const normalized = new Set() + for (const value of values) { + normalized.add(value) + const cn = value.match(/cn\s*=\s*([^,]+)/i)?.[1]?.trim() + if (cn) { + normalized.add(cn) + } + } + entry[attr] = Array.from(normalized) continue } if (Array.isArray(entry[attr])) { @@ -232,6 +271,7 @@ export class AuthProviderLDAP implements AuthProvider { } private createIdentity(entry: LdapUserEntry, password: string): CreateUserDto { + // Build the local identity payload from LDAP entry. const isAdmin = typeof this.ldapConfig.options.adminGroup === 'string' && this.ldapConfig.options.adminGroup && @@ -246,6 +286,7 @@ export class AuthProviderLDAP implements AuthProvider { } private getFirstNameAndLastName(entry: LdapUserEntry): { firstName: string; lastName: string } { + // Resolve name fields with structured and fallback attributes. // 1) Prefer structured attributes if (entry.sn && entry.givenName) { return { firstName: entry.givenName, lastName: entry.sn } @@ -263,6 +304,7 @@ export class AuthProviderLDAP implements AuthProvider { } private dbLogin(login: string): string { + // Normalize domain-qualified logins to the user part. if (login.includes('\\')) { return login.split('\\').slice(-1)[0] } @@ -270,6 +312,7 @@ export class AuthProviderLDAP implements AuthProvider { } private buildLdapLogin(login: string): string { + // Build the bind login string based on LDAP config. if (this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.UPN) { if (this.ldapConfig.upnSuffix && !login.includes('@')) { return `${login}@${this.ldapConfig.upnSuffix}` @@ -282,11 +325,15 @@ export class AuthProviderLDAP implements AuthProvider { return login } + private buildBindUserDN(ldapLogin: string): string { + return this.isAD ? ldapLogin : `${this.ldapConfig.attributes.login}=${ldapLogin},${this.ldapConfig.baseDN}` + } + private buildUserFilter(login: string, extraFilter?: string): string { - // Build a safe LDAP filter to search for a user. + // Build a safe LDAP filter to search for the user entry. // Important: - Values passed to EqualityFilter are auto-escaped by ldapts // - extraFilter is appended as-is (assumed trusted configuration) - // Output: (&(|(userPrincipalName=john.doe@sync-in.com)(sAMAccountName=john.doe)(cn=john.doe)(uid=john.doe)(mail=john.doe@sync-in.com))(*extraFilter*)) + // Note: The OR clause differs between AD and generic LDAP. // Handle the case where the sAMAccountName is provided in domain-qualified format (e.g., SYNC_IN\\user) // Note: sAMAccountName is always stored without the domain in Active Directory. @@ -315,4 +362,61 @@ export class AuthProviderLDAP implements AuthProvider { } return filterString } + + private hasAdminGroup(entry: LdapUserEntry, adminGroup: string): boolean { + // Check for the admin group in the normalized `memberOf` list. + return Array.isArray(entry[LDAP_COMMON_ATTR.MEMBER_OF]) && entry[LDAP_COMMON_ATTR.MEMBER_OF].includes(adminGroup) + } + + private async isMemberOfGroupOfNames(adminGroup: string, userDn: string, client: Client): Promise { + // Check groupOfNames membership by querying group entries. + // When adminGroup is a DN, search at the group DN; otherwise search under baseDN. + const { dn, cn } = this.parseAdminGroup(adminGroup) + // Build a filter that matches groupOfNames entries containing the user's DN as a member. + const filters = [ + new EqualityFilter({ attribute: LDAP_SEARCH_ATTR.OBJECT_CLASS, value: LDAP_SEARCH_ATTR.GROUP_OF_NAMES }), + new EqualityFilter({ attribute: LDAP_SEARCH_ATTR.MEMBER, value: userDn }) + ] + // If a CN is available, narrow the query to that specific group name. + if (cn) { + filters.splice(1, 0, new EqualityFilter({ attribute: LDAP_COMMON_ATTR.CN, value: cn })) + } + const filter = new AndFilter({ filters }).toString() + + try { + // Use BASE scope for an exact DN lookup, otherwise SUB to scan within baseDN. + const { searchEntries } = await client.search(dn || this.ldapConfig.baseDN, { + scope: dn ? LDAP_SEARCH_ATTR.BASE : LDAP_SEARCH_ATTR.SUB, + filter, + attributes: [LDAP_COMMON_ATTR.CN] + }) + // Any matching entry implies membership. + return searchEntries.length > 0 + } catch (e) { + this.logger.warn(`${this.isMemberOfGroupOfNames.name} - ${e}`) + return false + } + } + + private parseAdminGroup(adminGroup: string): { dn?: string; cn?: string } { + // Accept either full DN or simple CN and extract what we can for lookups. + const looksLikeDn = adminGroup.includes('=') && adminGroup.includes(',') + if (!looksLikeDn) { + return { cn: adminGroup } + } + const cn = adminGroup.match(/cn\s*=\s*([^,]+)/i)?.[1]?.trim() + return { dn: adminGroup, cn } + } + + private handleBindError(error: any, attemptedBindDN: string): InvalidCredentialsError | any { + // Prefer the most specific LDAP error when multiple errors are returned. + if (error?.errors?.length) { + for (const err of error.errors) { + this.logger.warn(`${this.checkAuth.name} - ${attemptedBindDN} : ${err}`) + } + return error.errors[error.errors.length - 1] + } + this.logger.warn(`${this.checkAuth.name} - ${attemptedBindDN} : ${error}`) + return error + } } diff --git a/environment/environment.dist.yaml b/environment/environment.dist.yaml index d0a5a01b..2935bb3c 100755 --- a/environment/environment.dist.yaml +++ b/environment/environment.dist.yaml @@ -114,26 +114,41 @@ auth: issuer: Sync-in # LDAP authentication ldap: - # e.g: [ldap://localhost:389, ldaps://localhost:636] (array required) + # e.g.: [ldap://localhost:389, ldaps://localhost:636] (array required) + # Multiple servers are tried in order until a bind/search succeeds. # required servers: [] - # baseDN: distinguished name (e.g., ou=people,dc=ldap,dc=sync-in,dc=com) + # baseDN: Distinguished name (e.g.: ou=people,dc=ldap,dc=sync-in,dc=com) + # Used as the search base for users, and for groups when adminGroup is a CN. # required baseDN: ou=people,dc=ldap,dc=sync-in,dc=com # filter, e.g: (acl=admin) + # Appended as-is to the LDAP search filter (trusted config). # optional filter: - # upnSuffix: AD domain suffix used with `userPrincipalName` to build UPN-style logins (e.g., user@`sync-in.com`) + # upnSuffix: AD domain suffix used with `userPrincipalName` to build UPN-style logins (e.g.: user@`sync-in.com`) + # Only used when login is set to userPrincipalName. # optional upnSuffix: - # netbiosName: NetBIOS domain name used with `sAMAccountName` to build legacy logins (e.g., `SYNC_IN`\user) + # netbiosName: NetBIOS domain name used with `sAMAccountName` to build legacy logins (e.g.: `SYNC_IN`\user) + # Only used when login is set to sAMAccountName. # optional netbiosName: + # serviceBindDN: Distinguished Name for a service account used to search users/groups. + # When set, searches are performed with this account; user bind is used only to validate the password. + # e.g.: cn=syncin,ou=services,dc=ldap,dc=sync-in,dc=com + # optional + serviceBindDN: + # serviceBindPassword: Password for the service account used to search users/groups. + # optional + serviceBindPassword: attributes: - # Login attribute used to construct the user's DN for binding. - # The value of this attribute is used as the naming attribute (first RDN) when forming the Distinguished Name (DN) during authentication - # login: `uid` | `cn` | `mail` | `sAMAccountName` | `userPrincipalName`, used to authenticate the user - # default: `uid` + # LDAP attribute that matches the login stored in the database. + # With a service bind, it is used to locate the user (then bind with the found DN). + # Without a service bind, it is used to construct the user's DN for binding (except AD: UPN/DOMAIN\\user). + # If you choose mail, local logins should be the user's email address. + # e.g.: uid | cn | mail | sAMAccountName | userPrincipalName + # default: uid login: uid # Attribute used to retrieve the user's email address # email: `mail` or `email` @@ -142,7 +157,7 @@ auth: options: # autoCreateUser: Automatically create a local user on first successful LDAP authentication. # The local account is created from LDAP attributes: - # - login: from the configured LDAP login attribute (e.g. uid, cn, sAMAccountName, userPrincipalName) + # - login: from the configured LDAP login attribute (e.g.: uid, cn, sAMAccountName, userPrincipalName) # - email: from the configured email attribute (required) # - firstName / lastName: from givenName+sn, or displayName, or cn (fallback) # When disabled, only existing users can authenticate via LDAP. @@ -155,8 +170,11 @@ auth: # e.g.: [personal_space, spaces_access] (array required) # default: [] autoCreatePermissions: [] - # adminGroup: Name of the LDAP group (CN) that grants Sync-in administrator privileges. - # If set, users whose LDAP `memberOf` contains this group name are assigned the administrator role. + # adminGroup: LDAP group that grants Sync-in administrator privileges. + # Accepts either a simple CN (e.g.: "Admins") or a full DN (e.g.: "CN=Admins,OU=Groups,DC=ldap,DC=sync-in,DC=com"). + # If set, users whose LDAP `memberOf` contains this CN (or whose group DN matches) are assigned the administrator role. + # If `memberOf` is missing, Sync-in can also check membership by searching `groupOfNames` groups. + # If users cannot read `groupOfNames`, use a service bind account to perform this lookup. # If not set, existing administrator users keep their role and it cannot be removed via LDAP. # optional adminGroup: @@ -167,7 +185,7 @@ auth: enablePasswordAuthFallback: true oidc: # issuerUrl: The URL of the OIDC provider's discovery endpoint - # Examples: + # e.g.: # - Keycloak: https://auth.example.com/realms/my-realm # - Authentik: https://auth.example.com/application/o/my-app/ # - Google: https://accounts.google.com @@ -183,7 +201,7 @@ auth: clientSecret: changeOIDCClientSecret # redirectUri: The callback URL where users are redirected after authentication # This URL must be registered in your OIDC provider's allowed redirect URIs - # Example (API callback): https://sync-in.domain.com/api/auth/oidc/callback + # e.g.: (API callback): https://sync-in.domain.com/api/auth/oidc/callback # # To allow authentication from the desktop application, the following redirect URLs must also be registered in your OIDC provider: # - http://127.0.0.1:49152/oidc/callback @@ -211,7 +229,7 @@ auth: # adminRoleOrGroup: Name of the role or group that grants Sync-in administrator access # Users with this value will be granted administrator privileges. # The value is matched against `roles` or `groups` claims provided by the IdP. - # Note: depending on the provider (e.g., Keycloak), roles/groups may be exposed only in tokens + # Note: depending on the provider (e.g.: Keycloak), roles/groups may be exposed only in tokens # and require proper IdP mappers to be included in the ID token or UserInfo response. # optional adminRoleOrGroup: @@ -280,7 +298,7 @@ applications: secret: onlyOfficeSecret # If no external server is configured, the local Nginx service from the Docker Compose setup is used. # If an external server is configured, it will be used instead. - # Note: when using an external server (e.g. https://onlyoffice.domain.com), make sure it is accessible from the client/browser. + # Note: when using an external server (e.g.: https://onlyoffice.domain.com), make sure it is accessible from the client/browser. # default: null externalServer: # If you use https, set to `true`. @@ -292,7 +310,7 @@ applications: enabled: false # If no external server is configured, the local Nginx service from the Docker Compose setup is used. # If an external server is configured, it will be used instead. - # Note: when using an external server (e.g. https://collabora.domain.com), make sure it is accessible from the client/browser. + # Note: when using an external server (e.g.: https://collabora.domain.com), make sure it is accessible from the client/browser. # default: null externalServer: appStore: