Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 96 additions & 11 deletions docs/Server/Access-Control.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,97 @@ Access Control
================

* [Overview](#overview)
* [Default Behaviour: SimpleAccessControl](#default-behaviour-simpleaccesscontrol)
* [Default Behaviour: Secure Default](#default-behaviour-secure-default)
* [Administrators](#administrators)
* [Manager (Break-Glass Super-User)](#manager-break-glass-super-user)
* [Rule-Based Access Control](#rule-based-access-control)
* [Rule Evaluation Order](#rule-evaluation-order)
* [Default Effect](#default-effect)
* [Subject Reference](#subject-reference)
* [Target Reference](#target-reference)
* [Attribute Rules](#attribute-rules)
* [Control Rules](#control-rules)
* [Extended Operation Rules](#extended-operation-rules)
* [Custom Access Control](#custom-access-control)

## Overview

Access control operates at three levels:
Access control operates at four levels:

- **Operation level**: Checked before each operation executes. Denial sends `INSUFFICIENT_ACCESS_RIGHTS` to the
client. Covers the following operations: Search, Add, Modify, Delete, ModifyDn, Compare, and PasswordModify.
- **Attribute level**: Checked for each attribute involved in Compare, Add, and Modify operations. Also applied to
each Search result entry: disallowed attributes are stripped before the entry is sent; if the entry itself is
denied at operation level, it is suppressed entirely from results (not sent to the client).
- **Control level**: Checked for *privileged* request controls (Relax Rules by default; configurable via
`ServerOptions::setPrivilegedControls()`). The control is inert unless an explicit grant permits the bound identity to
use it. See [Control Rules](#control-rules).
each Search result entry. Disallowed attributes are stripped before the entry is sent. If the entry itself is
denied at operation level, it is suppressed entirely from results.
- **Control level**: Checked for privileged request controls (Relax Rules by default, configurable via
`ServerOptions::setPrivilegedControls()`). See [Control Rules](#control-rules).
- **Extended operation level**: Checked for privileged extended operations, configurable via
`ServerOptions::setPrivilegedExtendedOps()`. See [Extended Operation Rules](#extended-operation-rules).

Rules are bundled in an `AclRules` object configured via `ServerOptions::setAclRules()`. See
[Configuration](Configuration.md).

Bind, WhoAmI, and StartTLS are handled before access control and are always permitted.

## Default Behaviour: SimpleAccessControl
## Default Behaviour: Secure Default

When no rules are configured, the server uses `SimpleAccessControl`: all operations are denied for anonymous clients
and allowed for authenticated clients. No attribute filtering is applied.
With no access control configured, the server applies a secure default (`AclRules::secureDefault()`). It configures:

- Anonymous clients are denied.
- Authenticated clients may perform general operations.
- `userPassword` can be changed only by the entry owner and the administrator, and is never returned in search results.
- Password Modify (RFC 3062) is limited to self and the administrator.
- Privileged controls and extended operations are limited to the administrator.

To keep this protection and add your own rules, start from the secure default and compose on top with the `with*`
methods. This is the recommended way to extend access control, for example to grant a privileged control:

```php
AclRules::secureDefault()
->withControlRules(ControlRule::allow(
Subject::dn('cn=replica,dc=example,dc=com'),
Target::subtree('dc=example,dc=com'),
Control::OID_SYNC_REQUEST,
));
```

Building from `new AclRules()` instead gives a blank policy with no credential protection, so a `userPassword` rule is
then yours to add. Composing on `secureDefault()` is the safe default. To apply just the credential protection to a
policy you build from scratch, use `AclRules::withCredentialProtection()`:

```php
(new AclRules())
->withOperationRules(/* your rules */)
->withCredentialProtection(Subject::group('cn=admins,ou=groups,dc=example,dc=com'));
```

### Administrators

`setAdministrators()` designates a directory administrator that the secure default grants password-reset and
privileged-operation rights. Point it at a DN or a group.

```php
$options->setAdministrators(Subject::dn('uid=admin,ou=people,dc=example,dc=com'));
$options->setAdministrators(Subject::group('cn=admins,ou=groups,dc=example,dc=com'));
```

Group membership is read from the group entry in the directory. With no administrator set, only self-service password
change works. Administrative resets then require a [manager](#manager-break-glass-super-user).

### Manager (Break-Glass Super-User)

`setManager()` configures an optional super-user that is not a directory entry. It is recognized at bind, bypasses
access control, and is exempt from password-policy lockout. The password is stored hashed and rotated through config.

```php
$options->setManager(new ManagerIdentity(
new Dn('cn=manager'),
'{SSHA}...hashed password...',
));
```

There is no default manager, and no name or password ships. Use it for recovery, not routine administration. On a
read-only replica it bypasses access control for reads, but writes are still referred to the provider.

## Rule-Based Access Control

Expand Down Expand Up @@ -229,6 +288,23 @@ use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule;

A client attaches the control with `Controls::relaxRules()`, e.g. `$client->create($entry, Controls::relaxRules())`.

## Extended Operation Rules

Privileged extended operations are gated per identity with `ExtendedOperationRule`s, keyed on the request OID (empty OID
list matches all). They are denied by default. The set of gated OIDs is configured with
`ServerOptions::setPrivilegedExtendedOps()`.

```php
use FreeDSx\Ldap\Server\AccessControl\Rule\ExtendedOperationRule;

(new AclRules())->withExtendedOperationRules(
ExtendedOperationRule::allow(
Subject::group('cn=admins,ou=groups,dc=example,dc=com'),
'1.3.6.1.4.1....',
),
);
```

## Custom Access Control

For cases where the built-in rule system is insufficient, implement `AccessControlInterface` and pass it via
Expand All @@ -239,8 +315,9 @@ use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Exception\OperationException;
use FreeDSx\Ldap\Operation\ResultCode;
use FreeDSx\Ldap\Operation\OperationType;
use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface;
use FreeDSx\Ldap\Server\AccessControl\OperationType;
use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess;
use FreeDSx\Ldap\Server\Token\TokenInterface;

class MyAccessControl implements AccessControlInterface
Expand All @@ -262,6 +339,7 @@ class MyAccessControl implements AccessControlInterface
TokenInterface $token,
Dn $dn,
string $attribute,
AttributeAccess $access,
): void {
// Throw OperationException to deny access to the attribute.
}
Expand All @@ -274,6 +352,13 @@ class MyAccessControl implements AccessControlInterface
// Throw OperationException to deny use of a privileged control.
}

public function authorizeExtendedOperation(
TokenInterface $token,
string $oid,
): void {
// Throw OperationException to deny use of a privileged extended operation.
}

/**
* Return null to suppress the entry from search results entirely.
*/
Expand Down
5 changes: 5 additions & 0 deletions docs/Server/Replication.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ synced, is your normal configuration. See [Access Control](Access-Control.md).

If you need to change which controls require an explicit grant, use `ServerOptions::setPrivilegedControls(...)`.

A sync consumer receives full entries, including attributes that are hidden from ordinary searches such as
`userPassword`, because a replica has to be a faithful copy. The sync control is the privileged gate that decides this,
so grant it only to identities you trust with the whole directory. Attribute read rules like the secure default's
`userPassword` restriction still apply to normal searches by that identity; they do not trim what replication sends.

## Poll vs Listen

The consumer chooses how it reads; the provider supports both:
Expand Down
15 changes: 6 additions & 9 deletions src/FreeDSx/Ldap/LdapServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
use FreeDSx\Ldap\Operation\Request\AddRequest;
use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface;
use FreeDSx\Ldap\Server\AccessControl\BackendAwareInterface;
use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl;
use FreeDSx\Ldap\Server\AccessControl\PrivilegedBypassAccessControl;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage;
use FreeDSx\Ldap\Server\Backend\Storage\Export\DirectoryDumper;
use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions;
Expand Down Expand Up @@ -196,7 +196,10 @@ private function init(): void
{
$this->requireBackendUnlessProxy();
$this->requireSharedStorageForForkingReplica();
$this->options->setAccessControl($this->resolveAccessControl());
// Wrap once so a privileged manager token bypasses whichever policy resolved, and inject the backend into it.
$this->options->setAccessControl($this->injectBackendIfNeeded(
new PrivilegedBypassAccessControl($this->resolveAccessControl()),
));
}

private function requireBackendUnlessProxy(): void
Expand Down Expand Up @@ -246,13 +249,7 @@ private function resolveAccessControl(): AccessControlInterface
return $this->injectBackendIfNeeded($this->accessControl);
}

$aclRules = $this->options->getAclRules();

if ($aclRules->isEmpty()) {
return $this->options->getAccessControl();
}

return $this->injectBackendIfNeeded(new RuleBasedAccessControl($aclRules));
return $this->injectBackendIfNeeded($this->options->getAccessControl());
}

private function injectBackendIfNeeded(AccessControlInterface $acl): AccessControlInterface
Expand Down
22 changes: 21 additions & 1 deletion src/FreeDSx/Ldap/Server/AccessControl/AccessControlInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Exception\OperationException;
use FreeDSx\Ldap\Operation\OperationType;
use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess;
use FreeDSx\Ldap\Server\Token\TokenInterface;

/**
Expand All @@ -38,14 +39,15 @@ public function authorizeOperation(
): void;

/**
* Assert that $token may access $attribute on $dn (gates Compare, Add, and Modify operations).
* Assert that $token may access $attribute on $dn for the given direction (Add and Modify are writes, Compare reads).
*
* @throws OperationException with ResultCode::INSUFFICIENT_ACCESS_RIGHTS on denial
*/
public function authorizeAttribute(
TokenInterface $token,
Dn $dn,
string $attribute,
AttributeAccess $access,
): void;

/**
Expand All @@ -59,6 +61,16 @@ public function authorizeControl(
string $controlOid,
): void;

/**
* Assert that the token may invoke the privileged extended operation identified by the OID (deny-by-default).
*
* @throws OperationException with ResultCode::INSUFFICIENT_ACCESS_RIGHTS on denial
*/
public function authorizeExtendedOperation(
TokenInterface $token,
string $oid,
): void;

/**
* Coarse, target-independent gate: whether $token could use the control against a target in general.
*
Expand All @@ -76,4 +88,12 @@ public function filterEntry(
TokenInterface $token,
Entry $entry,
): ?Entry;

/**
* Whether $token may see $entry at all, without stripping any attributes (the entry-level gate replication uses).
*/
public function isEntryVisible(
TokenInterface $token,
Entry $entry,
): bool;
}
Loading
Loading