Skip to content

Commit 8daf677

Browse files
authored
Merge pull request #95 from linagora/feat/manage-groups
Implement supplementary groups synchronization (#38)
2 parents 59894d1 + f41cb21 commit 8daf677

16 files changed

Lines changed: 1233 additions & 135 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ The module supports two authentication methods:
3232
- Auto-create Unix accounts on first login
3333
- Configurable shell, home directory, UID/GID ranges
3434
- Skeleton directory support
35+
- **[Group synchronization](doc/llng-configuration.md#group-synchronization)**:
36+
- Sync Unix supplementary groups from LLNG on each login
37+
- Automatic group creation if needed
38+
- Local whitelist for defense-in-depth (`allowed_managed_groups`)
39+
- Groups outside managed pool are never modified
3540
- **[Service accounts](doc/service-accounts.md)** (ansible, backup, etc.):
3641
- SSH key authentication without OIDC
3742
- Per-server configuration file

doc/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ rate_limit_max_attempts = 5
4545
rate_limit_initial_lockout = 30
4646
rate_limit_max_lockout = 3600
4747

48+
# Group synchronization (#38)
49+
# Local whitelist of groups allowed to be managed (optional, defense-in-depth)
50+
# If configured, only groups in this list AND in LLNG's managed_groups will be synced
51+
# allowed_managed_groups = docker,developers,readonly
52+
4853
# Webhook notifications (optional)
4954
# notify_enabled = true
5055
# notify_url = https://alerts.example.com/webhook

doc/llng-configuration.md

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,22 +51,23 @@ Additional and optional parameters that can be inserted into `lemonldap-ng.ini`,
5151

5252
### General Parameters
5353

54-
| Parameter | Default | Description |
55-
| ----------------------------------------------- | ------------ | --------------------------------------- |
56-
| `oidcServiceDeviceAuthorizationExpiration` | `600` (10mn) | Device authorization expiration time |
57-
| `oidcServiceDeviceAuthorizationPollingInterval` | `5` | Polling interval in seconds |
58-
| `oidcServiceDeviceAuthorizationUserCodeLength` | `8` | Length of user code |
59-
| `portalDisplayPamAccess` | `0` | Set to 1 (or a rule) to display PAM tab |
60-
| `pamAccessRp` | `pam-access` | OIDC Relying Party name |
61-
| `pamAccessTokenDuration` | `600` (10mn) | Token duration |
62-
| `pamAccessMaxDuration` | `3600` (1h) | Maximum token duration |
63-
| `pamAccessExportedVars` | `{}` | Exported variables |
64-
| `pamAccessOfflineTtl` | `86400` (1d) | Offline cache TTL |
65-
| `pamAccessSshRules` | `{}` | SSH access rules |
66-
| `pamAccessServerGroups` | `{}` | Server groups configuration |
67-
| `pamAccessSudoRules` | `{}` | Sudo rules |
68-
| `pamAccessOfflineEnabled` | `0` | Enable offline mode |
69-
| `pamAccessHeartbeatInterval` | `300` (5mn) | Heartbeat interval |
54+
| Parameter | Default | Description |
55+
| ----------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------- |
56+
| `oidcServiceDeviceAuthorizationExpiration` | `600` (10mn) | Device authorization expiration time |
57+
| `oidcServiceDeviceAuthorizationPollingInterval` | `5` | Polling interval in seconds |
58+
| `oidcServiceDeviceAuthorizationUserCodeLength` | `8` | Length of user code |
59+
| `portalDisplayPamAccess` | `0` | Set to 1 (or a rule) to display PAM tab |
60+
| `pamAccessRp` | `pam-access` | OIDC Relying Party name |
61+
| `pamAccessTokenDuration` | `600` (10mn) | Token duration |
62+
| `pamAccessMaxDuration` | `3600` (1h) | Maximum token duration |
63+
| `pamAccessExportedVars` | `{}` | Exported variables |
64+
| `pamAccessOfflineTtl` | `86400` (1d) | Offline cache TTL |
65+
| `pamAccessSshRules` | `{}` | SSH access rules |
66+
| `pamAccessServerGroups` | `{}` | Server groups configuration |
67+
| `pamAccessSudoRules` | `{}` | Sudo rules |
68+
| `pamAccessOfflineEnabled` | `0` | Enable offline mode |
69+
| `pamAccessHeartbeatInterval` | `300` (5mn) | Heartbeat interval |
70+
| `pamAccessManagedGroups` | `{}` | Unix groups managed by LLNG per server group (see [Group Synchronization](#group-synchronization)) |
7071

7172
When offline mode is enabled, the server-side cache is protected by
7273
[Cache Brute-Force Protection](security.md#cache-brute-force-protection).
@@ -179,6 +180,87 @@ Or during enrollment:
179180
sudo ob-enroll -g production
180181
```
181182

183+
## Group Synchronization
184+
185+
The group synchronization feature (#38) allows LemonLDAP::NG to manage Unix supplementary groups on target servers. When a user connects via SSH, their Unix groups are synchronized with the groups defined in LLNG.
186+
187+
### Configuration
188+
189+
In `lemonldap-ng.ini`, configure which groups LLNG should manage for each server group:
190+
191+
```perl
192+
pamAccessManagedGroups = {
193+
production => 'docker,developers,readonly',
194+
staging => 'developers,testers',
195+
bastion => 'operators,auditors',
196+
default => ''
197+
}
198+
```
199+
200+
- Groups listed in `pamAccessManagedGroups` will be created automatically on the server if they don't exist
201+
- Users are added to groups they're assigned to in LLNG
202+
- Users are removed from managed groups they're no longer assigned to in LLNG
203+
- Groups NOT in `pamAccessManagedGroups` are never modified (local groups are preserved)
204+
205+
### How It Works
206+
207+
```mermaid
208+
sequenceDiagram
209+
participant Client as SSH Client
210+
participant Server as Server (PAM)
211+
participant LLNG as LemonLDAP::NG
212+
213+
Client->>Server: ssh user@server
214+
Server->>LLNG: /pam/authorize
215+
LLNG-->>Server: {groups: ["dev","docker"],<br/>managed_groups: ["dev","docker","qa"]}
216+
Note over Server: Filter by local whitelist<br/>(if configured)
217+
Note over Server: Sync groups:<br/>• Add user to "dev", "docker"<br/>• Remove from "qa" (managed but not assigned)
218+
Server-->>Client: Session established
219+
```
220+
221+
### Security Considerations
222+
223+
- **Principle of least privilege**: Don't include privileged groups (sudo, wheel, admin) in `managed_groups`
224+
- **Audit trail**: All group modifications are logged with event type `GROUP_SYNC`
225+
- **Offline behavior**: Group sync uses cached group information when LLNG is unreachable
226+
- **File protection**: Group modifications use system tools (`groupadd`, `gpasswd`) which handle `/etc/group` and `/etc/gshadow` atomically
227+
228+
### Local Whitelist (Defense-in-Depth)
229+
230+
Administrators can optionally configure a local whitelist of groups allowed to be managed on each server. This provides defense-in-depth by restricting which groups LLNG can actually modify, regardless of what `managed_groups` it sends.
231+
232+
In `/etc/open-bastion/openbastion.conf`:
233+
234+
```ini
235+
# Only allow these groups to be managed by LLNG on this server
236+
allowed_managed_groups = docker,developers,readonly
237+
```
238+
239+
When configured:
240+
241+
- Groups must be in BOTH `pamAccessManagedGroups` (from LLNG) AND `allowed_managed_groups` (local) to be synced
242+
- Groups sent by LLNG but not in the local whitelist are silently ignored
243+
- This allows local administrators to have final control over which groups can be managed
244+
245+
**Use cases:**
246+
247+
- Restrict LLNG to manage only specific groups on sensitive servers
248+
- Allow different group policies per server even within the same server group
249+
- Provide a safety net against misconfigured LLNG policies
250+
251+
### Example: Per-Environment Groups
252+
253+
```perl
254+
# Developer groups differ by environment
255+
pamAccessManagedGroups = {
256+
production => 'app-users,readonly', # Read-only in prod
257+
staging => 'app-users,developers,docker', # Full dev access in staging
258+
bastion => 'operators' # Bastion operators only
259+
}
260+
```
261+
262+
When a user moves from staging to production access, their docker and developers group memberships are automatically removed on production servers.
263+
182264
## See Also
183265

184266
- [PAM Authentication Modes](pam-modes.md) - Configure PAM on servers

doc/security/02-ssh-connection.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,77 @@ cache_rate_limit_max_lockout_sec = 3600 # 1 heure max
12431243

12441244
---
12451245

1246+
### R-S13 - Manipulation des groupes Unix via synchronisation LLNG
1247+
1248+
| | Score |
1249+
| --------------- | :---: |
1250+
| **Probabilité** | 2 |
1251+
| **Impact** | 3 |
1252+
1253+
**Architectures concernées :** A, B, C, D (avec synchronisation de groupes activée)
1254+
1255+
**Description :** La fonctionnalité de synchronisation des groupes Unix (#38) permet à LLNG de gérer les groupes supplémentaires des utilisateurs sur les serveurs. Un attaquant pourrait exploiter cette fonctionnalité pour obtenir des privilèges supplémentaires.
1256+
1257+
**Vecteurs d'attaque :**
1258+
1259+
- Modification des groupes côté LLNG pour obtenir des accès (ex: groupe docker, wheel, admin)
1260+
- Attaque MITM sur la communication PAM-LLNG pour injecter des groupes
1261+
- Modification du cache offline pour ajouter des groupes non autorisés
1262+
- Symlink attack sur /etc/group pendant la modification
1263+
1264+
**Conséquence :** Un attaquant pourrait obtenir des privilèges supplémentaires sur le serveur (sudo, docker, accès à des ressources sensibles).
1265+
1266+
**Remédiation embarquée (IMPLÉMENTÉE) :**
1267+
1268+
Le module PAM implémente plusieurs contrôles de sécurité :
1269+
1270+
| Mesure de sécurité | Description |
1271+
| ------------------------------ | ----------------------------------------------------------------------------- |
1272+
| **Groupes gérés explicites** | Seuls les groupes listés dans `managed_groups` peuvent être modifiés |
1273+
| **Liste blanche locale** | `allowed_managed_groups` permet de restreindre davantage côté serveur |
1274+
| **Validation des noms** | Les noms de groupes sont validés (alphanum, tiret, underscore uniquement) |
1275+
| **Utilisation outils système** | `groupadd`/`gpasswd` pour manipulation atomique de /etc/group et /etc/gshadow |
1276+
| **Cache chiffré** | Les groupes sont stockés en cache avec AES-256-GCM |
1277+
| **Audit GROUP_SYNC** | Toutes les modifications de groupes sont journalisées |
1278+
1279+
**Configuration LLNG (server-side) :**
1280+
1281+
```yaml
1282+
# Configuration LLNG Manager
1283+
pamAccessManagedGroups:
1284+
production: "docker,developers,readonly"
1285+
bastion: "operators,auditors"
1286+
default: "" # Pas de sync par défaut
1287+
```
1288+
1289+
**Configuration locale (defense-in-depth) :**
1290+
1291+
```ini
1292+
# /etc/open-bastion/openbastion.conf
1293+
# Liste blanche locale optionnelle - restreint les groupes que LLNG peut gérer
1294+
allowed_managed_groups = docker,developers,readonly
1295+
```
1296+
1297+
La liste blanche locale offre une défense en profondeur :
1298+
1299+
- Les groupes doivent être dans LLNG `managed_groups` ET dans `allowed_managed_groups` local
1300+
- Permet aux administrateurs serveur de contrôler ce que LLNG peut modifier
1301+
- Protection contre une configuration LLNG erronée ou compromise
1302+
1303+
**Principe de moindre privilège :**
1304+
1305+
- Ne pas inclure les groupes critiques (wheel, sudo, root, admin) dans `managed_groups`
1306+
- Créer des groupes dédiés pour les accès applicatifs (ex: app-users, db-readers)
1307+
- Utiliser des `server_group` différents pour segmenter les accès
1308+
- Configurer `allowed_managed_groups` sur les serveurs sensibles
1309+
1310+
| | Score résiduel |
1311+
| --------------- | :--------------------------------------------------------------------------: |
1312+
| **Probabilité** | 1 (avec managed_groups restrictifs, liste blanche locale et validation noms) |
1313+
| **Impact** | 2 (groupes critiques non gérés) |
1314+
1315+
---
1316+
12461317
## 12. Comptes de Service
12471318

12481319
### Description

include/audit_log.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ typedef enum {
3030
AUDIT_ENROLLMENT_START,
3131
AUDIT_ENROLLMENT_SUCCESS,
3232
AUDIT_ENROLLMENT_FAILURE,
33-
AUDIT_USER_CREATED
33+
AUDIT_USER_CREATED,
34+
AUDIT_GROUP_SYNC
3435
} audit_event_type_t;
3536

3637
/* Audit event structure */

include/auth_cache.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818

1919
/* Authorization cache entry */
2020
typedef struct {
21-
int version; /* Cache format version (3) */
21+
int version; /* Cache format version (4) */
2222
time_t expires_at; /* Expiration timestamp */
2323
char *user; /* Username */
2424
bool authorized; /* Authorization result */
2525
char **groups; /* User groups */
2626
size_t groups_count; /* Number of groups */
27+
char **managed_groups; /* Pool of groups managed by LLNG */
28+
size_t managed_groups_count;
2729
bool sudo_allowed; /* Sudo permission */
2830
bool sudo_nopasswd; /* Sudo without password */
2931
char *gecos; /* Full name / GECOS */

include/config.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ typedef struct {
140140
int cache_rate_limit_max_attempts; /* Max failed lookups before lockout (default: 3) */
141141
int cache_rate_limit_lockout_sec; /* Initial lockout in seconds (default: 60) */
142142
int cache_rate_limit_max_lockout_sec; /* Maximum lockout in seconds (default: 3600) */
143+
144+
/* Group synchronization (#38) */
145+
char *allowed_managed_groups; /* Comma-separated whitelist of groups allowed to be managed locally (optional) */
143146
} pam_openbastion_config_t;
144147

145148
/*

include/ob_client.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ typedef struct {
3838
char *user;
3939
char **groups;
4040
size_t groups_count;
41+
char **managed_groups; /* Pool of groups managed by LLNG for this server */
42+
size_t managed_groups_count;
4143
char *reason;
4244
int expires_in;
4345
bool active; /* For introspection */

include/str_utils.h

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,60 @@ static inline char *str_json_strdup(struct json_object *obj)
3737
const char *str = json_object_get_string(obj);
3838
return str ? strdup(str) : NULL;
3939
}
40+
41+
/*
42+
* Parse a JSON array of strings into a dynamically allocated string array.
43+
* Only string elements are copied; non-strings are skipped.
44+
* Returns the array on success (caller must free each element and the array),
45+
* NULL on failure or empty array.
46+
* Sets *out_count to the number of valid strings copied.
47+
*/
48+
static inline char **str_json_parse_string_array(struct json_object *arr,
49+
size_t max_count,
50+
size_t *out_count)
51+
{
52+
*out_count = 0;
53+
54+
if (!arr || !json_object_is_type(arr, json_type_array)) {
55+
return NULL;
56+
}
57+
58+
size_t count = json_object_array_length(arr);
59+
if (count > max_count) {
60+
count = max_count;
61+
}
62+
if (count == 0) {
63+
return NULL;
64+
}
65+
66+
char **result = calloc(count + 1, sizeof(char *));
67+
if (!result) {
68+
return NULL;
69+
}
70+
71+
size_t valid_count = 0;
72+
for (size_t i = 0; i < count; i++) {
73+
struct json_object *elem = json_object_array_get_idx(arr, i);
74+
/* Only accept string elements, skip non-strings */
75+
if (elem && json_object_is_type(elem, json_type_string)) {
76+
const char *str = json_object_get_string(elem);
77+
if (str) {
78+
result[valid_count] = strdup(str);
79+
if (result[valid_count]) {
80+
valid_count++;
81+
}
82+
}
83+
}
84+
}
85+
86+
if (valid_count == 0) {
87+
free(result);
88+
return NULL;
89+
}
90+
91+
*out_count = valid_count;
92+
return result;
93+
}
4094
#endif
4195

4296
/*

0 commit comments

Comments
 (0)