PHP API
All classes live under Padosoft\Iam\Directory\. The four DTOs/policy are final readonly; the services are
final.
Contracts\DirectoryConnector
The transport seam. Fail-closed: null means denied / not found, never an exception.
namespace Padosoft\Iam\Directory\Contracts;
interface DirectoryConnector
{
public function authenticate(string $username, string $password): ?DirectoryUser;
public function find(string $username): ?DirectoryUser; // no-credentials lookup (sync/admin)
}
See Custom connector and Fail-closed transport.
DirectoryUser (final readonly)
Normalized identity, decoupled from the transport.
new DirectoryUser(
string $username,
?string $email = null,
bool $emailVerified = false,
?string $displayName = null,
array $groups = [], // list<string>: full DNs or short CNs
);
$user->normalizedEmail(): ?string; // lowercase + trim, or null
$user->emailDomain(): ?string; // domain part of the normalized email, or null
GroupMapper
new GroupMapper(array $map); // [DN-or-CN => full_key | list<full_key>]
$mapper->rolesFor(array $groups): array; // list<string> unique, sorted IAM roles; unmapped groups ignored
DN and CN are matched case-insensitively; the CN is extracted from a DN via ^cn=([^,]+). Default-deny: no
mapping → no role. See Group → role mapping.
DirectoryJitPolicy (final readonly)
new DirectoryJitPolicy(
bool $requireVerifiedEmail = true,
array $allowedDomains = [], // list<string>; [] = no restriction
bool $approvalRequired = false,
array $defaultRoles = [], // list<string> full_key (bootstrap)
bool $groupMapping = true,
array $protectedRoles = [], // list<string> never grantable via the directory
);
DirectoryJitPolicy::fromArray(array $config): self; // maps the `jit` config section
fromArray() is defensive: unknown/invalid values fall back to the secure defaults, and the list fields keep
only strings.
DirectoryProvisioner
$provisioner->provision(
DirectoryUser $user,
DirectoryJitPolicy $policy,
?string $organizationId,
array $mappedRoles, // list<string> from GroupMapper
): DirectoryOutcome;
Behaviour:
- evaluates the policy gate first (
pendingon failure — no writes); - computes
effective = (default ∪ mapped) − protected; - no existing user → creates
Userin aDB::transaction, syncs grants →provisioned; - existing directory-sourced user → reuses, syncs grants →
linked; - existing non-directory user →
conflict('email_taken_non_directory'), no writes; - sync is authoritative (adds missing, revokes stale
source=directorygrants, preserves manual grants) and
idempotent; it no-ops whenorganizationIdisnull.
The private sync() writes through the IAM server models User, Membership, Grant — see
Data model & contract.
DirectoryAuthenticator
new DirectoryAuthenticator(
DirectoryConnector $connector,
GroupMapper $mapper,
DirectoryProvisioner $provisioner,
array $config = [], // the `iam-directory` config section
);
$auth->login(string $username, string $password): DirectoryOutcome; // authenticate → map → provision
$auth->sync(DirectoryUser $user): DirectoryOutcome; // admin sync, no credentials
login() short-circuits to denied when the connector returns null. sync() runs the same
map → policy → provision path for an already-resolved user. Reads config['jit'] (policy) and
config['organization_id'] (scope).
DirectoryOutcome (final readonly)
$outcome->status; // 'provisioned' | 'linked' | 'conflict' | 'pending' | 'denied'
$outcome->userId; // ?string — set for provisioned/linked
$outcome->reason; // ?string — set for pending/conflict/denied
$outcome->roles; // list<string> — roles granted in this pass
$outcome->ok(); // bool — true iff status ∈ { provisioned, linked }
DirectoryOutcome::provisioned(string $userId, array $roles): self;
DirectoryOutcome::linked(string $userId, array $roles): self;
DirectoryOutcome::pending(string $reason): self;
DirectoryOutcome::conflict(string $reason): self; // email taken by a non-directory account
DirectoryOutcome::denied(): self; // reason = 'invalid_credentials'
See Outcomes & reasons for every reason string.
Ldap\LdapConnector (optional)
The real LDAP/AD transport via LdapRecord. Requires ext-ldap + directorytree/ldaprecord-laravel
(a suggest). Isolated under src/Ldap/ and excluded from PHPStan so the core stays LDAP-free.
new LdapConnector(
LdapRecord\Connection $connection,
string $model, // class-string<LdapRecord\Models\Model>
string $usernameAttribute = 'samaccountname',
);
Maps mail→email, cn→displayName, memberof→groups (DNs); a present mail ⇒ emailVerified = true. Every
failure (unknown user, empty password, rejected/erroring bind) → null. See LDAP setup.
IamDirectoryServiceProvider
Registers GroupMapper, DirectoryProvisioner and DirectoryAuthenticator as singletons and publishes the
iam-directory config. Does not bind DirectoryConnector — you must. See
Installation.
Related
- Config keys — the config contract.
- Outcomes & reasons — the result vocabulary.
- Core concepts — how these classes fit together.