LDAP / Active Directory Authentication — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. T7 is controller-driven — do NOT delegate it to a subagent.
Goal: Enterprise directory login (CORE platform, no license gate). Users authenticate with their AD/LDAP credentials on the existing login form; they are JIT-provisioned on first success (provider='ldap', no password hash), receive Obscura roles mapped from their AD groups, and — under an admin-selectable LDAP_MODE=only posture — local passwords are disabled for everyone except break-glass admins. The login form is unchanged: local password is tried first, then (for unknown or ldap-provider users) the directory. Change-password is rejected for directory users. Everything is opt-in and disabled by default so the demo and every existing deployment are byte-for-byte unchanged.
Architecture:
- Provider is a first-class column now. The codebase records identity provenance in user_identities(idp, subject) and domain.User has no provider field (verified: go/internal/auth/adapters/pg.go:162, go/internal/auth/domain/auth.go). The spec's login/rejection//me logic all key off users.provider, so this plan adds a provider text NOT NULL DEFAULT 'local' column to users (migration 00082), stamped by UpsertUserByIdentity to the idp value and backfilled from user_identities. This makes the spec's "a user is either local or ldap, decided by users.provider" literally true and gives one-column reads for login branching, the change-password guard, and /me. (Deviation from the "single migration 00082" hint — see Self-review. The mapping tables are a second migration 00083.)
- Login orchestration lives in the auth Service. A new LoginWithDirectory method plus three optional collaborator ports — DirectoryAuthenticator (LDAP, nil = disabled), AdminChecker (break-glass), GroupRoleReconciler — attached via a SetDirectory(...) setter (the repo's Set*-collaborator idiom; NewService's signature stays intact so the four existing test call-sites compile). When the directory is disabled, LoginWithDirectory reduces exactly to today's VerifyPassword.
- Group→role mapping reuses the rbac binding store. Two tables (ldap_group_roles, ldap_role_grants) + a reconciler owned by the auth context; the reconciler applies/removes bindings through a narrow RoleBinder port that the rbac *Store already satisfies (BindRole/UnbindRole/RefreshEffectivePerms, verified go/internal/rbac/app/ports.go:101-113). Manually-assigned roles have no ledger row and are never touched.
- Admin surface reuses the existing tab pattern: a "Directory (LDAP)" tab on the Admin page — a read-only status card + a group-DN↔role mapping table — gated on rbac.admin (the same perm the Roles manager uses, verified go/internal/httpapi/server.go:617-625).
Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations), one new Go dep github.com/go-ldap/ldap/v3 (MIT, pure Go). React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client. LDAP test rig via a compose profile (osixia/openldap), same opt-in pattern as the OIDC test IdPs.
Spec: docs/superpowers/specs/2026-07-04-ldap-ad-design.md
Global Constraints (every task)
- NEVER
go test— the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify iscd go && go build ./... && go vet ./...only (vet compiles test files too, so keep existing tests compiling — the fourauthapp.NewService(...)/app.NewService(...)call-sites stay valid because theNewServicesignature is unchanged). - Web verify:
cd web && npx tsc --noEmit && npx vite build. npm run gen:apiafter ANYapi/openapi.yamledit (regeneratesweb/src/api/schema.ts). Run it fromweb/. Commit the regeneratedschema.tswith the task.- NO new npm dependencies (
npm installis broken: npm11/node25 arborist crash). The one new Go dep is fine (connected build host —client_golangwas added the same way). Rungo get github.com/go-ldap/ldap/v3 && go mod tidy; commitgo/go.mod+go/go.sum. - Deploy ONLY from repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert/me enabled_modules == [ai, correspondence, esign, semantic, watermarking](dev-logindirector@obscura.local, host port 38080). - LDAP is DISABLED by default in the deploy env (
LDAP_ENABLEDunset → false). The main demoobscuraservice never turns it on; the demo is untouched. The e2e rig runs a separate obscura instance (T7). - Commit per task on
main, do NOT push. NEVERgit add -A(go/obscura-serveris a tracked ELF binary) — alwaysgit addexplicit paths. - Auth errors stay GENERIC — every directory failure mode (unreachable, 0/>1 results, wrong password, disabled) returns the SAME
auth.login.invalid"invalid email or password" error as local login. No user enumeration. - i18n en/id parity (tsc-enforced): feature-co-located
web/src/features/admin/i18n.ts+web/src/api/me.tsconsumers; nested groups;enandididentical in shape. Smart-quote gotcha: the Edit tool can mangle’/“”; after editing an i18n file verifynpx tsc --noEmitand if quotes broke, rewrite the whole file with Write. - Fail-closed config:
LDAP_ENABLED=truewith a missingLDAP_URL/LDAP_BASE_DN, anldaps://URL together withLDAP_START_TLS, or an invalidLDAP_MODE→ boot error, consistent with every other provider'sValidate. - Clean up all e2e artifacts (delete JIT-provisioned
provider='ldap'users + theirldap_role_grants/ldap_group_rolesrows from the shared demo DB; stop + remove the rig containers; restore any touched env).
File Map
| File | Task | Role |
|---|---|---|
go/go.mod, go/go.sum |
T1 | add github.com/go-ldap/ldap/v3 |
go/internal/platform/config/config.go |
T1 | LDAPConfig + LDAP field + validate() in Validate |
go/internal/auth/adapters/ldap.go (new) |
T1 | LDAPAuthenticator (dial/TLS/StartTLS/CA, search bind, user bind, attr+group fetch, atomic last-bind, Status()) |
go/migrations/00082_user_provider.sql (new) |
T2 | users.provider column + backfill |
go/internal/auth/domain/auth.go |
T2 | User.Provider field |
go/internal/auth/adapters/pg.go |
T2 | provider in userColumns/userColumnsAliased/scanUser + UpsertUserByIdentity INSERT |
go/internal/auth/app/directory.go (new) |
T2 | DirectoryIdentity/DirectoryStatus + DirectoryAuthenticator/AdminChecker/GroupRoleReconciler ports |
go/internal/auth/app/service.go |
T2 | directory fields, SetDirectory, LoginWithDirectory, DirectoryStatus, change-password guard |
go/internal/httpapi/handlers_auth.go |
T2/T5 | PasswordLogin → LoginWithDirectory; /me exposes provider/is_directory |
go/migrations/00083_ldap_group_roles.sql (new) |
T3 | ldap_group_roles + ldap_role_grants |
go/internal/auth/app/ldap.go (new) |
T3 | GroupRoleService (mapping CRUD + Reconcile) + DirectoryGrantStore/RoleBinder ports |
go/internal/auth/adapters/ldap_grants_pg.go (new) |
T3 | LDAPGrantStore (2-table Postgres store) |
go/cmd/obscura-server/wire.go |
T2/T3/T4 | build authenticator/reconciler/admin-checker, SetDirectory, LDAPGroupRoles Dep |
go/internal/httpapi/handlers_ldap.go (new) |
T4 | status + group-role CRUD handlers |
go/internal/httpapi/server.go |
T4 | Deps.LDAPGroupRoles, server field, /admin/ldap/* routes |
api/openapi.yaml |
T4 | ldap status + group-roles paths & schemas |
web/src/api/schema.ts |
T4 | regenerated via gen:api |
web/src/api/me.ts |
T5 | provider/isDirectory on Me |
web/src/features/profile/ProfilePage.tsx |
T5 | hide change-password for directory users |
web/src/features/admin/data.ts |
T5 | useLdapStatus/useLdapGroupRoles/useUpsertLdapGroupRole/useDeleteLdapGroupRole |
web/src/features/admin/LdapTab.tsx (new) |
T5 | status card + mapping CRUD table |
web/src/features/admin/AdminPage.tsx |
T5 | register the tab |
web/src/features/admin/i18n.ts |
T5 | admin.tabs.ldap + admin.ldap.* (en/id) |
deploy/docker-compose.yml |
T6 | ldap compose profile (osixia/openldap + seed) |
deploy/ldap/*.ldif (new) |
T6 | memberof overlay config + seed users/groups |
docs/LDAP.md (new) |
T6 | operator guide (what to request from the AD admin, env, troubleshooting) |
Scouted anchors (verified this session)
- No
users.providercolumn exists.users=id, email, display_name, phone, is_service, created_at, disabled, quota_bytes(+password_hash), select list atgo/internal/auth/adapters/pg.go:162;domain.Useratgo/internal/auth/domain/auth.gohas the matching 8 fields. Provider today lives ONLY inuser_identities(idp, subject)(00005_auth.sql), written byUpsertUserByIdentity(pg.go:42-79) withidp ∈ {dev, local, oidc}. → this plan adds the column (T2). - JIT shape:
Service.ProvisionFromOIDC→repo.UpsertUserByIdentity(ctx, "oidc", subject, email, name)(service.go:50). LDAP JIT mirrors it withidp="ldap",subject=userDN(service.gonewLoginWithDirectory). - Local login:
Service.VerifyPassword(service.go:67-95) — genericauth.login.invalidon every failure;hash==""accepts the demopassword==emailconvention.PasswordLoginhandler (handlers_auth.go:53-77) calls it. No forgot-password endpoint exists — onlyPOST /me/password→ChangePassword→Service.UpdatePassword(service.go:238-256); the directory guard goes there. - rbac binding store (grant/revoke):
BindRole(ctx, roleID, subjectKind, subjectID)/UnbindRole(...)/RefreshEffectivePerms(ctx)(go/internal/rbac/app/ports.go:101-113, implgo/internal/rbac/adapters/pg.go:442-469,532). Subject kind for a user binding is the literal"user"(rbacapp.SubjectUser,ports.go:20).RolesForSubjects(ctx, kind, ids) → map[id][]roleName(ports.go:106). - Roles-manager perm =
rbac.admin(server.go:617-625). LDAP admin endpoints reuse it. - Break-glass admin = the wildcard role named
"admin"(carries the"admin"permission;wire.go:706-712ensureAdmin)./mederivesis_adminfrom the union of user-bound + position-bound role NAMES containing"admin"(handlers_auth.go:162-182).AdminCheckerreplicates this at login time viarbacStore.RolesForSubjects+dirSvc.PositionsForUser. PositionsForUser:dirSvc.PositionsForUser(ctx, userID, time.Now())(used inmiddleware.go:54;dirSvcis*directoryapp.Service)./me(handlers_auth.go:150-226) exposesroles,is_admin,enabled_modules, license, etc. — does NOT expose provider today. T5 addsprovider+is_directory.- Config patterns:
Config.Validate()(config.go:382-438) is a fail-closed switch chain; sub-configs (OIDCConfigatconfig.go:361-367) have avalidate()called from it (config.go:434).stringsalready imported (config.go:8)._FILE,fileenv indirection exists for secrets but LDAP uses a plainLDAP_BIND_PASSWORD(passed via--env-file, like the Mekari secrets). - wire.go:
authSvc := authapp.NewService(authadapters.NewStore(database), authadapters.NewArgon2Hasher(), authadapters.NewTOTP(), oidcVerifier, nil)(wire.go:135);dirSvc(:168),rbacStore(:169),utcNow := func() time.Time {...}(:181) all exist before the seeding block (:191-229). httpapiDepsstruct (server.go:72) + literal are the injection points. - Next free migrations:
00081taken →00082(user_provider) +00083(ldap tables). roles.idisuuid(00004_rbac.sql:13) → FK target forldap_group_roles.role_id/ldap_role_grants.role_id.- Admin tab pattern:
AdminPage.tsxregisters tabs as<Tab>{t('admin.tabs.X')}</Tab>(:106-116) + matching<TabPanel><XTab/></TabPanel>;AiTab.tsxis the template (statusStructuredListcard + settings +useAiSettings/useSaveAiSettingshooks inadmin/data.ts, which importsapi, ok from '@/api/client'). Roles dropdown source:useRoles()→Role[] = {id,name}(web/src/api/rbac.ts:39, filters managed roles). - Profile change-password:
ProfilePage.tsx:117-128Security section renders the "Change password" button;mecomes fromuseMe()(@/api/me). - Compose OIDC test-IdP pattern:
keycloak/casdoorservices withprofiles: ["keycloak"]/["casdoor"](docker-compose.yml:204-216), not in the default stack. Theldapprofile mirrors this. The compose network lets containers reach each other by service name (postgres:5432,minio:9000). - T7 rig precedent: the Peruri e2e (2026-07-02, memory
obscura-peruri-emeterai.md) ran a 2nd obscura instance on:18099sharing the demo Postgres+MinIO with different env (split provider), demo untouched. T7 reuses this shape (see T7 for the exactdocker run).
Task 1: Config LDAP_* envs (fail-closed) + go-ldap dep + LDAPAuthenticator
Files: go/go.mod, go/go.sum, go/internal/platform/config/config.go, go/internal/auth/adapters/ldap.go (new).
Interface produced (consumed by T2 wiring): config.LDAPConfig (+ Config.LDAP, validated at boot) and authadapters.NewLDAPAuthenticator(LDAPOptions, *slog.Logger) (*LDAPAuthenticator, error) whose Authenticate(ctx, login, password) (app.DirectoryIdentity, error) does search-bind → user-bind → attribute/group fetch, plus Status()/Mode().
-
[ ] Step 1 — Add the dependency. From
go/:go get github.com/go-ldap/ldap/v3@latest && go mod tidy. (Pure-Go, MIT.go mod tidykeeps it onceldap.goimports it in Step 4 — run tidy again after Step 4 if needed, or add the import first.) -
[ ] Step 2 —
LDAPConfig. Editgo/internal/platform/config/config.go. Add the struct nearOIDCConfig(~line 367):
// LDAPConfig configures directory (LDAP/AD) authentication. Disabled by default so the demo
// and every existing deployment are unchanged. When enabled, the login form transparently
// authenticates directory users (JIT-provisioned, provider='ldap') in addition to (mixed) or
// instead of (only) local passwords. The defaults are AD-friendly (objectClass=user,
// mail/sAMAccountName, memberOf); an OpenLDAP rig overrides USER_FILTER/attrs (see docs/LDAP.md).
type LDAPConfig struct {
Enabled bool `env:"LDAP_ENABLED" envDefault:"false"`
URL string `env:"LDAP_URL"` // ldap://host:389 or ldaps://host:636
StartTLS bool `env:"LDAP_START_TLS" envDefault:"false"` // upgrade plain 389 to TLS; mutually exclusive with ldaps
CAFile string `env:"LDAP_CA_FILE"` // optional PEM for a private CA
InsecureSkipVerify bool `env:"LDAP_INSECURE_SKIP_VERIFY" envDefault:"false"` // TESTING ONLY; logged loudly
BindDN string `env:"LDAP_BIND_DN"` // read-only service account for the search bind
BindPassword string `env:"LDAP_BIND_PASSWORD"`
BaseDN string `env:"LDAP_BASE_DN"` // e.g. dc=corp,dc=example,dc=id
UserFilter string `env:"LDAP_USER_FILTER" envDefault:"(&(objectClass=user)(|(mail=%s)(sAMAccountName=%s)))"` // %s = escaped login
AttrEmail string `env:"LDAP_ATTR_EMAIL" envDefault:"mail"`
AttrName string `env:"LDAP_ATTR_NAME" envDefault:"displayName"`
AttrGroups string `env:"LDAP_ATTR_GROUPS" envDefault:"memberOf"`
Mode string `env:"LDAP_MODE" envDefault:"mixed"` // mixed | only
}
Add the field to the Config struct (near OIDC OIDCConfig, ~line 43):
LDAP LDAPConfig
Wire the validator into Config.Validate() (right after the OIDC.validate() call, ~line 434-436):
if err := c.LDAP.validate(); err != nil {
return err
}
Add the validator (next to OIDCConfig.validate, ~line 455):
func (l LDAPConfig) validate() error {
if !l.Enabled {
return nil
}
if l.URL == "" {
return fmt.Errorf("config: LDAP_URL is required when LDAP_ENABLED=true")
}
if l.BaseDN == "" {
return fmt.Errorf("config: LDAP_BASE_DN is required when LDAP_ENABLED=true")
}
if l.StartTLS && strings.HasPrefix(strings.ToLower(l.URL), "ldaps://") {
return fmt.Errorf("config: LDAP_START_TLS is mutually exclusive with an ldaps:// LDAP_URL — choose one TLS mode")
}
switch l.Mode {
case "mixed", "only":
default:
return fmt.Errorf("config: invalid LDAP_MODE %q (want mixed|only)", l.Mode)
}
return nil
}
- [ ] Step 3 — Authenticator. Create
go/internal/auth/adapters/ldap.go:
package adapters
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"strings"
"sync/atomic"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/Virtue-Digital-Indonesia/obscura/internal/auth/app"
)
const ldapOpTimeout = 8 * time.Second
// LDAPAuthenticator implements app.DirectoryAuthenticator against an LDAP/AD server. It is
// constructed only when LDAP is enabled (nil otherwise). Each Authenticate dials a fresh
// connection (closed on return); the only retained state is the atomic last-bind telemetry
// surfaced by Status() for the admin status card.
type LDAPAuthenticator struct {
url string
startTLS bool
tlsConfig *tls.Config
bindDN string
bindPassword string
baseDN string
userFilter string
attrEmail string
attrName string
attrGroups string
mode string
logger *slog.Logger
lastBindOK atomic.Bool
lastBindUnix atomic.Int64 // unix seconds of the last successful service bind; 0 = never
}
// LDAPOptions is the constructor input (mirrors config.LDAPConfig; decoupled so auth/adapters
// does not import platform/config).
type LDAPOptions struct {
URL, BindDN, BindPassword, BaseDN, UserFilter, AttrEmail, AttrName, AttrGroups, Mode, CAFile string
StartTLS, InsecureSkipVerify bool
}
// NewLDAPAuthenticator builds the authenticator, loading the optional CA file into a pool and
// deriving the TLS ServerName from the URL host.
func NewLDAPAuthenticator(opts LDAPOptions, logger *slog.Logger) (*LDAPAuthenticator, error) {
tlsCfg := &tls.Config{InsecureSkipVerify: opts.InsecureSkipVerify} //nolint:gosec // opt-in, logged below
if opts.InsecureSkipVerify && logger != nil {
logger.Warn("LDAP_INSECURE_SKIP_VERIFY is set — the directory server's TLS certificate is NOT verified; use only for testing")
}
if u, err := url.Parse(opts.URL); err == nil && u.Hostname() != "" {
tlsCfg.ServerName = u.Hostname()
}
if opts.CAFile != "" {
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("ldap: read CA file %q: %w", opts.CAFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ldap: CA file %q contained no valid PEM certificates", opts.CAFile)
}
tlsCfg.RootCAs = pool
}
return &LDAPAuthenticator{
url: opts.URL, startTLS: opts.StartTLS, tlsConfig: tlsCfg,
bindDN: opts.BindDN, bindPassword: opts.BindPassword, baseDN: opts.BaseDN,
userFilter: opts.UserFilter, attrEmail: opts.AttrEmail, attrName: opts.AttrName,
attrGroups: opts.AttrGroups, mode: opts.Mode, logger: logger,
}, nil
}
// Mode returns the configured posture (mixed|only).
func (a *LDAPAuthenticator) Mode() string { return a.mode }
// Status returns the read-only health/config summary for GET /admin/ldap/status.
func (a *LDAPAuthenticator) Status() app.DirectoryStatus {
st := app.DirectoryStatus{Enabled: true, URL: a.url, Mode: a.mode, BaseDN: a.baseDN, LastBindOK: a.lastBindOK.Load()}
if u := a.lastBindUnix.Load(); u > 0 {
t := time.Unix(u, 0).UTC()
st.LastBindAt = &t
}
return st
}
// dial opens a connection honoring ldap:// vs ldaps:// and optional StartTLS.
func (a *LDAPAuthenticator) dial() (*ldap.Conn, error) {
dialer := ldap.DialWithDialer(&net.Dialer{Timeout: ldapOpTimeout})
if strings.HasPrefix(strings.ToLower(a.url), "ldaps://") {
return ldap.DialURL(a.url, dialer, ldap.DialWithTLSConfig(a.tlsConfig))
}
conn, err := ldap.DialURL(a.url, dialer)
if err != nil {
return nil, err
}
if a.startTLS {
if err := conn.StartTLS(a.tlsConfig); err != nil {
conn.Close()
return nil, fmt.Errorf("starttls: %w", err)
}
}
return conn, nil
}
// Authenticate performs the service-account search bind, requires exactly one match, binds AS
// the found user with the submitted password, and returns the identity + groups. The login
// input is escaped with ldap.EscapeFilter before templating into USER_FILTER, so filter
// injection is impossible. Every error is returned as-is; the caller maps ALL of them to a
// single generic invalid-credentials error (no enumeration).
func (a *LDAPAuthenticator) Authenticate(ctx context.Context, login, password string) (app.DirectoryIdentity, error) {
_ = ctx // go-ldap v3 has no ctx-aware ops; per-op deadline is set via SetTimeout below
if login == "" || password == "" {
return app.DirectoryIdentity{}, errors.New("ldap: empty login or password")
}
conn, err := a.dial()
if err != nil {
a.lastBindOK.Store(false)
return app.DirectoryIdentity{}, fmt.Errorf("ldap: dial: %w", err)
}
defer conn.Close()
conn.SetTimeout(ldapOpTimeout)
// 1) service-account search bind
if err := conn.Bind(a.bindDN, a.bindPassword); err != nil {
a.lastBindOK.Store(false)
return app.DirectoryIdentity{}, fmt.Errorf("ldap: service bind: %w", err)
}
a.lastBindOK.Store(true)
a.lastBindUnix.Store(time.Now().Unix())
// 2) escaped search — exactly one result required
safe := ldap.EscapeFilter(login)
nverb := strings.Count(a.userFilter, "%s")
args := make([]any, nverb)
for i := range args {
args[i] = safe
}
filter := fmt.Sprintf(a.userFilter, args...)
req := ldap.NewSearchRequest(
a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, int(ldapOpTimeout.Seconds()), false,
filter, []string{a.attrEmail, a.attrName, a.attrGroups}, nil,
)
res, err := conn.Search(req)
if err != nil {
return app.DirectoryIdentity{}, fmt.Errorf("ldap: search: %w", err)
}
if len(res.Entries) != 1 {
return app.DirectoryIdentity{}, fmt.Errorf("ldap: expected exactly 1 result, got %d", len(res.Entries))
}
entry := res.Entries[0]
// 3) bind AS the user (rebind the same conn) with the submitted password
if err := conn.Bind(entry.DN, password); err != nil {
return app.DirectoryIdentity{}, fmt.Errorf("ldap: user bind: %w", err)
}
// 4) extract attributes; fall back email→login (the search matched it) so JIT always has one
id := app.DirectoryIdentity{
DN: entry.DN,
Email: entry.GetAttributeValue(a.attrEmail),
DisplayName: entry.GetAttributeValue(a.attrName),
Groups: entry.GetAttributeValues(a.attrGroups),
}
if id.Email == "" {
id.Email = login
}
return id, nil
}
var _ app.DirectoryAuthenticator = (*LDAPAuthenticator)(nil)
(This file imports app, whose new DirectoryIdentity/DirectoryStatus/DirectoryAuthenticator types land in T2 Step 3. Implement T2 Step 3 before compiling T1, or accept that go build is only green after T2 Step 3 — sequence T1→T2 without an intermediate build.)
- [ ] Step 4 — Verify + commit. After T2 Step 3 exists:
cd go && go build ./... && go vet ./.... Commit:
git add go/go.mod go/go.sum go/internal/platform/config/config.go go/internal/auth/adapters/ldap.go
git commit -m "feat(auth): LDAP config (fail-closed) + go-ldap authenticator (search+user bind, escaped filter)"
(If T2 Step 3's directory.go is not yet committed when you build, stage it here too — the two tasks are tightly coupled; committing T1's files together with T2 Step 3 is acceptable. Prefer: do T2 Step 3 first, commit it with T1.)
Task 2 (ADVERSARIAL-review this task — the login branching is the security core): users.provider, directory ports, LoginWithDirectory, change-password guard
Files: go/migrations/00082_user_provider.sql (new), go/internal/auth/domain/auth.go, go/internal/auth/adapters/pg.go, go/internal/auth/app/directory.go (new), go/internal/auth/app/service.go, go/internal/httpapi/handlers_auth.go, go/cmd/obscura-server/wire.go.
Login-branch contract (spec §Login flow, reread precisely):
- Known user, provider != 'ldap' → LOCAL path ONLY. A failed local verify does NOT fall through to LDAP. Under LDAP_MODE=only, reject the local password unless the user holds the break-glass admin role.
- Unknown user, OR known user with provider == 'ldap' → DIRECTORY path (search bind → user bind → JIT provider='ldap', no hash → group reconcile).
- Directory disabled (s.dir == nil) → reduces exactly to VerifyPassword (identical to today).
- Every failure → the SAME auth.login.invalid. TOTP is unchanged (it runs in the handler layer after either backend succeeds — untouched here).
- [ ] Step 1 — Migration. Create
go/migrations/00082_user_provider.sql:
-- +goose Up
-- Denormalized identity-backend marker on users, so the login path decides local-vs-directory
-- in ONE column read (a user is either local or ldap). Existing rows are backfilled from their
-- primary user_identities.idp; new rows are stamped by UpsertUserByIdentity. Default 'local'
-- keeps any identity-less row (there should be none) on the local password path — behavior is
-- unchanged until a user is provisioned via LDAP.
ALTER TABLE users ADD COLUMN provider text NOT NULL DEFAULT 'local';
UPDATE users u
SET provider = COALESCE(
(SELECT i.idp FROM user_identities i WHERE i.user_id = u.id ORDER BY i.created_at, i.id LIMIT 1),
'local');
-- +goose Down
ALTER TABLE users DROP COLUMN provider;
- [ ] Step 2 — Domain field. Edit
go/internal/auth/domain/auth.go. Add totype User struct(afterQuotaBytes int64):
// Provider is the identity backend that owns this account: "local"/"dev" (password or
// dev-login), "oidc", or "ldap". A directory ('ldap') account has NO local password and may
// not change-password. Denormalized from the user's primary user_identities.idp.
Provider string
- [ ] Step 3 — Directory ports. Create
go/internal/auth/app/directory.go:
package app
import (
"context"
"time"
)
// DirectoryIdentity is the identity asserted by a successful directory (LDAP/AD) bind.
type DirectoryIdentity struct {
DN string // the bound user's distinguished name — the stable JIT subject
Email string
DisplayName string
Groups []string // raw group DN strings from LDAP_ATTR_GROUPS (memberOf by default)
}
// DirectoryStatus is the read-only health/config summary for GET /admin/ldap/status.
type DirectoryStatus struct {
Enabled bool
URL string
Mode string
BaseDN string
LastBindOK bool
LastBindAt *time.Time
}
// DirectoryAuthenticator authenticates a login+password against an external directory and
// returns the asserted identity. nil on the Service = directory login disabled.
type DirectoryAuthenticator interface {
Authenticate(ctx context.Context, login, password string) (DirectoryIdentity, error)
Status() DirectoryStatus
Mode() string
}
// AdminChecker reports whether a user holds the wildcard break-glass admin role (directly or
// via a held position). Consulted ONLY in LDAP_MODE=only so an operator can still log in with a
// local password when the directory is down. nil → no break-glass (directory-only for all).
type AdminChecker interface {
HoldsAdmin(ctx context.Context, userID string) (bool, error)
}
// GroupRoleReconciler applies a directory user's group memberships to their Obscura role grants
// after a successful directory login. Best-effort: a failure is logged and NON-FATAL to the
// login. nil → group→role mapping disabled.
type GroupRoleReconciler interface {
Reconcile(ctx context.Context, userID string, groupDNs []string) error
}
- [ ] Step 4 — Provider column in the store. Edit
go/internal/auth/adapters/pg.go: userColumns(~line 162) → append, provider:
const userColumns = `id, email, display_name, phone, is_service, created_at, disabled, quota_bytes, provider`
userColumnsAliased(~line 164-167) → append the aliased column:
func userColumnsAliased(alias string) string {
return alias + `.id, ` + alias + `.email, ` + alias + `.display_name, ` + alias + `.phone, ` +
alias + `.is_service, ` + alias + `.created_at, ` + alias + `.disabled, ` + alias + `.quota_bytes, ` +
alias + `.provider`
}
scanUser(~line 169-171) → append&u.Provider:
func scanUser(u *domain.User) []any {
return []any{&u.ID, &u.Email, &u.DisplayName, &u.Phone, &u.IsService, &u.CreatedAt, &u.Disabled, &u.QuotaBytes, &u.Provider}
}
UpsertUserByIdentityINSERT (~line 62-64) → stampprovider = idp:
if _, err := ex.Exec(ctx,
`INSERT INTO users (id, email, display_name, provider) VALUES ($1, $2, $3, $4)`, uid, email, name, idp); err != nil {
return fmt.Errorf("auth create user: %w", err)
}
(The UserByEmail query at pg.go:102 uses userColumns, so it now returns provider automatically. No other query changes needed — every user read goes through userColumns/scanUser.)
- [ ] Step 5 — Service: fields, setter, orchestration, guard. Edit
go/internal/auth/app/service.go:
5a. Add "log/slog" to the import block (errors, strings already present).
5b. Extend the Service struct (~line 21-27) with the directory collaborators:
type Service struct {
repo Repository
hasher Hasher
totp TOTP
verifier TokenVerifier // nil when OIDC is not configured
now func() time.Time
dir DirectoryAuthenticator // nil when LDAP disabled
admin AdminChecker // nil when no break-glass check wired
reconciler GroupRoleReconciler // nil when group→role mapping disabled
logger *slog.Logger // optional; best-effort directory-path logging
}
5c. Add the setter + DirectoryStatus (after NewService, ~line 35):
// SetDirectory attaches the optional directory-login collaborators (LDAP authenticator,
// break-glass admin checker, group→role reconciler) and a logger. Leaving dir nil keeps the
// login path identical to today (local password only). Wired in the composition root after
// rbac/directory exist, following the repo's Set*-collaborator idiom.
func (s *Service) SetDirectory(dir DirectoryAuthenticator, admin AdminChecker, reconciler GroupRoleReconciler, logger *slog.Logger) {
s.dir = dir
s.admin = admin
s.reconciler = reconciler
s.logger = logger
}
// DirectoryStatus returns the directory-login status (admin status card) and whether a
// directory backend is configured at all.
func (s *Service) DirectoryStatus() (DirectoryStatus, bool) {
if s.dir == nil {
return DirectoryStatus{Enabled: false}, false
}
return s.dir.Status(), true
}
5d. Add LoginWithDirectory (near VerifyPassword, ~line 95):
// LoginWithDirectory authenticates an email+password across the local and directory (LDAP/AD)
// backends per the transparent-same-form policy (see the login-branch contract in the plan).
// When the directory is disabled (s.dir == nil) it reduces EXACTLY to VerifyPassword. Every
// failure returns the same generic invalid-credentials error as local login (no enumeration).
func (s *Service) LoginWithDirectory(ctx context.Context, email, password string) (domain.User, error) {
invalid := &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "auth.login.invalid", Message: "invalid email or password"}
if email == "" || password == "" {
return domain.User{}, invalid
}
existing, _, err := s.repo.UserByEmail(ctx, email)
known := err == nil
if err != nil {
var ke *kernel.Error
if !(errors.As(err, &ke) && ke.Kind == kernel.ErrNotFound) {
return domain.User{}, err // a real lookup error, not "absent"
}
}
// Local path: a known non-ldap user, OR the directory being disabled entirely. A failed
// local verify NEVER falls through to LDAP for a local-provider user.
if (known && existing.Provider != "ldap") || s.dir == nil {
if s.dir != nil && s.dir.Mode() == "only" && known {
isAdmin := false
if s.admin != nil {
if ok, aerr := s.admin.HoldsAdmin(ctx, existing.ID); aerr == nil {
isAdmin = ok
}
}
if !isAdmin {
return domain.User{}, invalid // directory-only: local passwords disabled for non-admins
}
}
return s.VerifyPassword(ctx, email, password)
}
// Directory path: unknown user, or a known provider='ldap' user.
id, aerr := s.dir.Authenticate(ctx, email, password)
if aerr != nil {
if s.logger != nil {
s.logger.Warn("directory authenticate failed", "login", email, "err", aerr)
}
return domain.User{}, invalid
}
subject := id.DN
if subject == "" {
subject = strings.ToLower(email)
}
user, perr := s.repo.UpsertUserByIdentity(ctx, "ldap", subject, id.Email, id.DisplayName)
if perr != nil {
return domain.User{}, perr
}
if s.reconciler != nil {
if rerr := s.reconciler.Reconcile(ctx, user.ID, id.Groups); rerr != nil && s.logger != nil {
s.logger.Warn("directory group reconcile failed", "user", user.ID, "err", rerr)
}
}
return user, nil
}
5e. Change-password guard. In UpdatePassword (~line 242-244), right after the GetUser succeeds and BEFORE the current-password verify, reject directory users:
user, err := s.repo.GetUser(ctx, userID)
if err != nil {
return err
}
if user.Provider == "ldap" {
return &kernel.Error{Kind: kernel.ErrValidation, Code: "auth.ldap.managed_by_directory", Message: "your password is managed by your directory (LDAP/AD) — change it there"}
}
- [ ] Step 6 — Handler uses the orchestrator. Edit
go/internal/httpapi/handlers_auth.go. InPasswordLogin(~line 63) swapVerifyPasswordforLoginWithDirectory:
user, err := s.auth.LoginWithDirectory(ctx, body.Email, body.Password)
(Nothing else in the handler changes — the session issue, cookie, and metrics are identical. DevLogin/Register/OIDCLogin are untouched. dev-login stays dev-only and directory-independent.)
- [ ] Step 7 — Wire the directory (build authenticator + admin checker + reconciler). Edit
go/cmd/obscura-server/wire.go. Insert this block after the capability-role seeding (~line 212, wheredirSvc,rbacStore,utcNow,logger,authSvcall exist):
// Directory (LDAP/AD) login: build the authenticator when enabled (nil otherwise), a
// break-glass admin checker, and the group→role reconciler, then attach them to authSvc.
// LDAP is disabled by default (config fail-closed), so the demo login path is unchanged.
var directoryAuth authapp.DirectoryAuthenticator
if cfg.LDAP.Enabled {
la, lerr := authadapters.NewLDAPAuthenticator(authadapters.LDAPOptions{
URL: cfg.LDAP.URL, StartTLS: cfg.LDAP.StartTLS, CAFile: cfg.LDAP.CAFile,
InsecureSkipVerify: cfg.LDAP.InsecureSkipVerify, BindDN: cfg.LDAP.BindDN,
BindPassword: cfg.LDAP.BindPassword, BaseDN: cfg.LDAP.BaseDN, UserFilter: cfg.LDAP.UserFilter,
AttrEmail: cfg.LDAP.AttrEmail, AttrName: cfg.LDAP.AttrName, AttrGroups: cfg.LDAP.AttrGroups,
Mode: cfg.LDAP.Mode,
}, logger)
if lerr != nil {
return fmt.Errorf("ldap authenticator: %w", lerr)
}
directoryAuth = la
logger.Info("LDAP directory login enabled", "url", cfg.LDAP.URL, "mode", cfg.LDAP.Mode, "base_dn", cfg.LDAP.BaseDN)
}
ldapGrantStore := authadapters.NewLDAPGrantStore(database) // T3
ldapGroupRoleSvc := authapp.NewGroupRoleService(ldapGrantStore, rbacStore) // T3
authSvc.SetDirectory(directoryAuth, ldapAdminChecker{rbac: rbacStore, dir: dirSvc, clock: utcNow}, ldapGroupRoleSvc, logger)
Add the admin-checker helper type near the other unexported wire helpers (e.g. next to ensureAdmin, ~line 706):
// ldapAdminChecker answers "does this user hold the wildcard break-glass admin role" for the
// LDAP_MODE=only local-login exception, resolving the SAME way GET /me does: roles bound to the
// user UNION roles bound to any held position; the wildcard role is named "admin".
type ldapAdminChecker struct {
rbac *rbacadapters.Store
dir *directoryapp.Service
clock func() time.Time
}
func (c ldapAdminChecker) HoldsAdmin(ctx context.Context, userID string) (bool, error) {
if byUser, err := c.rbac.RolesForSubjects(ctx, "user", []string{userID}); err == nil {
for _, rn := range byUser[userID] {
if rn == "admin" {
return true, nil
}
}
}
positions, err := c.dir.PositionsForUser(ctx, userID, c.clock())
if err != nil || len(positions) == 0 {
return false, nil
}
byPos, err := c.rbac.RolesForSubjects(ctx, "position", positions)
if err != nil {
return false, nil
}
for _, rns := range byPos {
for _, rn := range rns {
if rn == "admin" {
return true, nil
}
}
}
return false, nil
}
(All referenced identifiers — authapp, authadapters, rbacadapters, directoryapp, context, time, fmt — are already imported in wire.go. ldapGroupRoleSvc is also passed to the httpapi Deps in T4 Step 3; the T3 types NewLDAPGrantStore/NewGroupRoleService land in T3, so sequence T3 before compiling this block — or stub-then-fill. Recommended order: T2 Steps 1-6, then T3, then this Step 7, then build.)
- [ ] Step 8 — Verify + commit.
cd go && go build ./... && go vet ./...(green only after T3 exists — see the note; if you are strictly per-task, commit T2 Steps 1-6 first, then wire in the T3 commit). Recommended single commit for the coupled auth core:
git add go/migrations/00082_user_provider.sql go/internal/auth/domain/auth.go go/internal/auth/adapters/pg.go go/internal/auth/app/directory.go go/internal/auth/app/service.go go/internal/httpapi/handlers_auth.go
git commit -m "feat(auth): users.provider + LoginWithDirectory orchestration + change-password guard for ldap users"
(Defer the wire.go hunk to T3's commit so the tree builds at each commit.)
Task 3: Migration 00083 + group→role mapping store + reconciler
Files: go/migrations/00083_ldap_group_roles.sql (new), go/internal/auth/app/ldap.go (new), go/internal/auth/adapters/ldap_grants_pg.go (new), go/cmd/obscura-server/wire.go (the T2 Step 7 block).
Reconcile contract (spec §Group→role mapping): on each directory login, match the user's group DNs (case-insensitive) against ldap_group_roles; grant newly-matched roles (record a ldap_role_grants row + BindRole), revoke ledger grants whose group no longer matches (UnbindRole + delete the row); RefreshEffectivePerms on any change. Manually-assigned roles (no ledger row) are never touched. Errors bubble to the caller, which logs them non-fatally.
- [ ] Step 1 — Migration. Create
go/migrations/00083_ldap_group_roles.sql:
-- +goose Up
-- Admin-managed AD-group-DN → Obscura-role mapping. role_id references the rbac roles catalogue
-- (roles.id is uuid); ON DELETE CASCADE so deleting a role drops its mappings automatically.
CREATE TABLE ldap_group_roles (
group_dn text PRIMARY KEY,
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Ledger of role bindings CREATED by directory reconciliation, so they can be revoked when a
-- user leaves a group WITHOUT touching manually-assigned roles (which have no ledger row). One
-- row per (user, role); group_dn records which mapping granted it. Cascades on user/role delete.
CREATE TABLE ldap_role_grants (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
group_dn text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX ldap_role_grants_user ON ldap_role_grants (user_id);
-- +goose Down
DROP TABLE ldap_role_grants;
DROP TABLE ldap_group_roles;
- [ ] Step 2 — App: ports +
GroupRoleService. Creatego/internal/auth/app/ldap.go:
package app
import (
"context"
"strings"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
// subjectKindUser mirrors rbacapp.SubjectUser ("user") — the subject kind for a per-user role
// binding. Duplicated here to avoid the auth context importing rbac's app package.
const subjectKindUser = "user"
// GroupRoleMapping is one AD-group-DN → Obscura-role-id mapping (admin-managed).
type GroupRoleMapping struct {
GroupDN string
RoleID string
}
// RoleGrant is one reconciliation-ledger entry (a role granted to a user for a group).
type RoleGrant struct {
RoleID string
GroupDN string
}
// DirectoryGrantStore persists the group→role mappings and the reconciliation ledger.
type DirectoryGrantStore interface {
ListMappings(ctx context.Context) ([]GroupRoleMapping, error)
UpsertMapping(ctx context.Context, groupDN, roleID string) error
DeleteMapping(ctx context.Context, groupDN string) error
GrantsForUser(ctx context.Context, userID string) ([]RoleGrant, error)
InsertGrant(ctx context.Context, userID, roleID, groupDN string) error
DeleteGrant(ctx context.Context, userID, roleID string) error
}
// RoleBinder is the narrow slice of the rbac write-service this context needs to apply
// directory-derived role grants. The rbac *Store satisfies it (BindRole/UnbindRole/
// RefreshEffectivePerms).
type RoleBinder interface {
BindRole(ctx context.Context, roleID, subjectKind, subjectID string) error
UnbindRole(ctx context.Context, roleID, subjectKind, subjectID string) error
RefreshEffectivePerms(ctx context.Context) error
}
// GroupRoleService owns the admin CRUD over group→role mappings AND reconciles a directory
// user's group memberships into Obscura role bindings on login. It satisfies
// GroupRoleReconciler.
type GroupRoleService struct {
store DirectoryGrantStore
binder RoleBinder
}
// NewGroupRoleService wires the group-role service.
func NewGroupRoleService(store DirectoryGrantStore, binder RoleBinder) *GroupRoleService {
return &GroupRoleService{store: store, binder: binder}
}
// ListMappings returns the admin-managed group→role mappings.
func (s *GroupRoleService) ListMappings(ctx context.Context) ([]GroupRoleMapping, error) {
return s.store.ListMappings(ctx)
}
// UpsertMapping validates and stores a group_dn → role_id mapping.
func (s *GroupRoleService) UpsertMapping(ctx context.Context, groupDN, roleID string) error {
groupDN = strings.TrimSpace(groupDN)
if groupDN == "" {
return &kernel.Error{Kind: kernel.ErrValidation, Code: "ldap.mapping.group_required", Message: "group DN is required"}
}
if strings.TrimSpace(roleID) == "" {
return &kernel.Error{Kind: kernel.ErrValidation, Code: "ldap.mapping.role_required", Message: "role id is required"}
}
return s.store.UpsertMapping(ctx, groupDN, roleID)
}
// DeleteMapping removes a group→role mapping.
func (s *GroupRoleService) DeleteMapping(ctx context.Context, groupDN string) error {
if strings.TrimSpace(groupDN) == "" {
return &kernel.Error{Kind: kernel.ErrValidation, Code: "ldap.mapping.group_required", Message: "group DN is required"}
}
return s.store.DeleteMapping(ctx, groupDN)
}
// Reconcile brings a user's directory-derived role grants in line with their current group
// memberships: grant roles for newly-matched groups (recording a ledger row), revoke grants
// whose group is no longer present, NEVER touching manually-assigned roles (no ledger row).
// Group-DN comparison is case-insensitive. Refreshes the effective-perms read model on any
// change. Errors are returned; the caller logs them non-fatally.
func (s *GroupRoleService) Reconcile(ctx context.Context, userID string, groupDNs []string) error {
mappings, err := s.store.ListMappings(ctx)
if err != nil {
return err
}
memberOf := make(map[string]bool, len(groupDNs))
for _, g := range groupDNs {
memberOf[strings.ToLower(strings.TrimSpace(g))] = true
}
// Desired grants: role_id → granting group_dn for every mapping the user matches.
desired := map[string]string{}
for _, m := range mappings {
if memberOf[strings.ToLower(strings.TrimSpace(m.GroupDN))] {
desired[m.RoleID] = m.GroupDN
}
}
current, err := s.store.GrantsForUser(ctx, userID)
if err != nil {
return err
}
have := make(map[string]bool, len(current))
changed := false
// Revoke ledger grants no longer desired.
for _, g := range current {
have[g.RoleID] = true
if _, keep := desired[g.RoleID]; !keep {
if err := s.binder.UnbindRole(ctx, g.RoleID, subjectKindUser, userID); err != nil {
return err
}
if err := s.store.DeleteGrant(ctx, userID, g.RoleID); err != nil {
return err
}
changed = true
}
}
// Grant newly-desired roles.
for roleID, groupDN := range desired {
if have[roleID] {
continue
}
if err := s.binder.BindRole(ctx, roleID, subjectKindUser, userID); err != nil {
return err
}
if err := s.store.InsertGrant(ctx, userID, roleID, groupDN); err != nil {
return err
}
changed = true
}
if changed {
return s.binder.RefreshEffectivePerms(ctx)
}
return nil
}
var _ GroupRoleReconciler = (*GroupRoleService)(nil)
- [ ] Step 3 — Adapter: the 2-table store. Create
go/internal/auth/adapters/ldap_grants_pg.go:
package adapters
import (
"context"
"fmt"
"github.com/Virtue-Digital-Indonesia/obscura/internal/auth/app"
"github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)
// LDAPGrantStore persists group→role mappings (ldap_group_roles) and the reconciliation ledger
// (ldap_role_grants). Every statement runs through s.db.Exec(ctx) (no own tx), like the other
// auth stores. Implements app.DirectoryGrantStore.
type LDAPGrantStore struct {
db *db.DB
}
// NewLDAPGrantStore constructs the store.
func NewLDAPGrantStore(d *db.DB) *LDAPGrantStore { return &LDAPGrantStore{db: d} }
// ListMappings returns every group_dn → role_id mapping (ordered).
func (s *LDAPGrantStore) ListMappings(ctx context.Context) ([]app.GroupRoleMapping, error) {
rows, err := s.db.Exec(ctx).Query(ctx, `SELECT group_dn, role_id FROM ldap_group_roles ORDER BY group_dn`)
if err != nil {
return nil, fmt.Errorf("ldap list mappings: %w", err)
}
defer rows.Close()
var out []app.GroupRoleMapping
for rows.Next() {
var m app.GroupRoleMapping
if err := rows.Scan(&m.GroupDN, &m.RoleID); err != nil {
return nil, fmt.Errorf("ldap scan mapping: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// UpsertMapping inserts or replaces a group_dn → role_id mapping.
func (s *LDAPGrantStore) UpsertMapping(ctx context.Context, groupDN, roleID string) error {
if _, err := s.db.Exec(ctx).Exec(ctx,
`INSERT INTO ldap_group_roles (group_dn, role_id) VALUES ($1, $2)
ON CONFLICT (group_dn) DO UPDATE SET role_id = EXCLUDED.role_id`, groupDN, roleID); err != nil {
return fmt.Errorf("ldap upsert mapping: %w", err)
}
return nil
}
// DeleteMapping removes a mapping (no-op when absent).
func (s *LDAPGrantStore) DeleteMapping(ctx context.Context, groupDN string) error {
if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM ldap_group_roles WHERE group_dn = $1`, groupDN); err != nil {
return fmt.Errorf("ldap delete mapping: %w", err)
}
return nil
}
// GrantsForUser returns the user's reconciliation-ledger rows.
func (s *LDAPGrantStore) GrantsForUser(ctx context.Context, userID string) ([]app.RoleGrant, error) {
rows, err := s.db.Exec(ctx).Query(ctx, `SELECT role_id, group_dn FROM ldap_role_grants WHERE user_id = $1`, userID)
if err != nil {
return nil, fmt.Errorf("ldap grants for user: %w", err)
}
defer rows.Close()
var out []app.RoleGrant
for rows.Next() {
var g app.RoleGrant
if err := rows.Scan(&g.RoleID, &g.GroupDN); err != nil {
return nil, fmt.Errorf("ldap scan grant: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// InsertGrant records a reconciliation grant (idempotent on (user_id, role_id)).
func (s *LDAPGrantStore) InsertGrant(ctx context.Context, userID, roleID, groupDN string) error {
if _, err := s.db.Exec(ctx).Exec(ctx,
`INSERT INTO ldap_role_grants (user_id, role_id, group_dn) VALUES ($1, $2, $3)
ON CONFLICT (user_id, role_id) DO UPDATE SET group_dn = EXCLUDED.group_dn`, userID, roleID, groupDN); err != nil {
return fmt.Errorf("ldap insert grant: %w", err)
}
return nil
}
// DeleteGrant removes a ledger row.
func (s *LDAPGrantStore) DeleteGrant(ctx context.Context, userID, roleID string) error {
if _, err := s.db.Exec(ctx).Exec(ctx, `DELETE FROM ldap_role_grants WHERE user_id = $1 AND role_id = $2`, userID, roleID); err != nil {
return fmt.Errorf("ldap delete grant: %w", err)
}
return nil
}
var _ app.DirectoryGrantStore = (*LDAPGrantStore)(nil)
- [ ] Step 4 — Verify + commit (this commit closes the T2 Step 7 wire.go hunk too, so the tree builds).
cd go && go build ./... && go vet ./...:
git add go/migrations/00083_ldap_group_roles.sql go/internal/auth/app/ldap.go go/internal/auth/adapters/ldap_grants_pg.go go/cmd/obscura-server/wire.go
git commit -m "feat(auth): LDAP group→role mapping store + login-time reconciler (grant/revoke via rbac bindings)"
Task 4: Admin API — GET /admin/ldap/status, GET/PUT/DELETE /admin/ldap/group-roles + OpenAPI + gen:api
Files: go/internal/httpapi/handlers_ldap.go (new), go/internal/httpapi/server.go, go/cmd/obscura-server/wire.go (Deps literal), api/openapi.yaml, web/src/api/schema.ts (regenerated).
Interface produced: rbac.admin-gated GET /api/v1/admin/ldap/status → {enabled,url,mode,base_dn,last_bind_ok,last_bind_at}; GET /api/v1/admin/ldap/group-roles → {group_roles:[{group_dn,role_id}]}; PUT body {group_dn, role_id} (upsert, 200 echo); DELETE ?group_dn= (204).
- [ ] Step 1 — Handlers. Create
go/internal/httpapi/handlers_ldap.go:
package httpapi
import (
"encoding/json"
"net/http"
"time"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
// ldapStatusView is the JSON for GET /admin/ldap/status.
type ldapStatusView struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
Mode string `json:"mode"`
BaseDN string `json:"base_dn"`
LastBindOK bool `json:"last_bind_ok"`
LastBindAt *string `json:"last_bind_at"`
}
// GetLDAPStatus returns the directory-login status card. Enabled=false (empty rest) when LDAP
// is not configured for this deployment.
func (s *Server) GetLDAPStatus(w http.ResponseWriter, r *http.Request) {
st, _ := s.auth.DirectoryStatus()
view := ldapStatusView{Enabled: st.Enabled, URL: st.URL, Mode: st.Mode, BaseDN: st.BaseDN, LastBindOK: st.LastBindOK}
if st.LastBindAt != nil {
iso := st.LastBindAt.Format(time.RFC3339)
view.LastBindAt = &iso
}
writeJSON(w, http.StatusOK, view)
}
// ldapGroupRoleView is one group_dn↔role_id mapping row.
type ldapGroupRoleView struct {
GroupDN string `json:"group_dn"`
RoleID string `json:"role_id"`
}
// ListLDAPGroupRoles returns the admin-managed group→role mappings.
func (s *Server) ListLDAPGroupRoles(w http.ResponseWriter, r *http.Request) {
ms, err := s.ldapGroupRoles.ListMappings(r.Context())
if err != nil {
writeProblem(w, err)
return
}
out := make([]ldapGroupRoleView, 0, len(ms))
for _, m := range ms {
out = append(out, ldapGroupRoleView{GroupDN: m.GroupDN, RoleID: m.RoleID})
}
writeJSON(w, http.StatusOK, map[string]any{"group_roles": out})
}
// PutLDAPGroupRole upserts one group→role mapping.
func (s *Server) PutLDAPGroupRole(w http.ResponseWriter, r *http.Request) {
var body struct {
GroupDN string `json:"group_dn"`
RoleID string `json:"role_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
return
}
if err := s.ldapGroupRoles.UpsertMapping(r.Context(), body.GroupDN, body.RoleID); err != nil {
writeProblem(w, err)
return
}
writeJSON(w, http.StatusOK, ldapGroupRoleView{GroupDN: body.GroupDN, RoleID: body.RoleID})
}
// DeleteLDAPGroupRole removes a mapping selected by ?group_dn=.
func (s *Server) DeleteLDAPGroupRole(w http.ResponseWriter, r *http.Request) {
if err := s.ldapGroupRoles.DeleteMapping(r.Context(), r.URL.Query().Get("group_dn")); err != nil {
writeProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
- [ ] Step 2 — Server wiring. Edit
go/internal/httpapi/server.go: - In
Deps(nearNotifyPrefs *notifyapp.PrefsService,~line 80) add:LDAPGroupRoles *authapp.GroupRoleService. - In the server struct (near
notifyPrefs *notifyapp.PrefsService) add:ldapGroupRoles *authapp.GroupRoleService. - In the constructor mapping (near
notifyPrefs: d.NotifyPrefs,~line 164) add:ldapGroupRoles: d.LDAPGroupRoles,. - Register routes right after the rbac roles block (
~line 626, before the Platform-administration comment):
// Directory (LDAP/AD) administration: read-only status + group→role mapping CRUD.
// Same rbac.admin gate as the Roles manager.
r.With(s.requirePerm("rbac.admin")).Get("/admin/ldap/status", s.GetLDAPStatus)
r.With(s.requirePerm("rbac.admin")).Get("/admin/ldap/group-roles", s.ListLDAPGroupRoles)
r.With(s.requirePerm("rbac.admin")).Put("/admin/ldap/group-roles", s.PutLDAPGroupRole)
r.With(s.requirePerm("rbac.admin")).Delete("/admin/ldap/group-roles", s.DeleteLDAPGroupRole)
(authapp is already imported in server.go.)
- [ ] Step 3 — Deps literal. In
go/cmd/obscura-server/wire.go, the httpapiDeps{...}literal (whereNotifyPrefs: notifyPrefsSvcis passed) add:
LDAPGroupRoles: ldapGroupRoleSvc,
- [ ] Step 4 — OpenAPI paths. Edit
api/openapi.yaml. Add underpaths:(near the other/api/v1/admin/*reporting paths):
/api/v1/admin/ldap/status:
get:
operationId: getLdapStatus
summary: Directory (LDAP/AD) login status
description: Read-only status of the LDAP directory backend for the admin status card. Requires rbac.admin.
tags: [admin]
responses:
'200':
description: Directory status (enabled=false when LDAP is not configured).
content:
application/json:
schema:
$ref: '#/components/schemas/LdapStatus'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
/api/v1/admin/ldap/group-roles:
get:
operationId: listLdapGroupRoles
summary: List LDAP group→role mappings
description: The admin-managed AD-group-DN → Obscura-role mappings. Requires rbac.admin.
tags: [admin]
responses:
'200':
description: The mappings.
content:
application/json:
schema:
$ref: '#/components/schemas/LdapGroupRoleList'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
put:
operationId: putLdapGroupRole
summary: Upsert an LDAP group→role mapping
description: Insert or replace the role mapped to a group DN. Requires rbac.admin.
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LdapGroupRole'
responses:
'200':
description: The stored mapping.
content:
application/json:
schema:
$ref: '#/components/schemas/LdapGroupRole'
'400':
$ref: '#/components/responses/Problem'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
delete:
operationId: deleteLdapGroupRole
summary: Delete an LDAP group→role mapping
description: Remove the mapping for the group DN given as the group_dn query parameter. Requires rbac.admin.
tags: [admin]
parameters:
- in: query
name: group_dn
required: true
schema: { type: string }
responses:
'204':
description: Deleted (idempotent).
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
- [ ] Step 5 — OpenAPI schemas. Add under
components: schemas::
LdapStatus:
type: object
description: Read-only status of the LDAP directory login backend.
properties:
enabled: { type: boolean }
url: { type: string }
mode: { type: string, enum: [mixed, only] }
base_dn: { type: string }
last_bind_ok: { type: boolean }
last_bind_at: { type: string, format: date-time, nullable: true }
required: [enabled, url, mode, base_dn, last_bind_ok]
LdapGroupRole:
type: object
description: One AD-group-DN → Obscura-role mapping.
properties:
group_dn: { type: string }
role_id: { type: string }
required: [group_dn, role_id]
LdapGroupRoleList:
type: object
properties:
group_roles:
type: array
items: { $ref: '#/components/schemas/LdapGroupRole' }
required: [group_roles]
- [ ] Step 6 — Regenerate + verify.
cd web && npm run gen:api && npx tsc --noEmit; thencd go && go build ./... && go vet ./.... - [ ] Step 7 — Commit:
git add go/internal/httpapi/handlers_ldap.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(api): admin LDAP status + group-role mapping CRUD (rbac.admin) + OpenAPI"
Task 5: Web — Directory (LDAP) admin tab + /me provider + hide change-password for ldap users
Files: web/src/api/me.ts, web/src/features/profile/ProfilePage.tsx, web/src/features/admin/data.ts, web/src/features/admin/LdapTab.tsx (new), web/src/features/admin/AdminPage.tsx, web/src/features/admin/i18n.ts. Also the Go /me handler (handlers_auth.go).
- [ ] Step 1 —
/meexposes provider (Go). Editgo/internal/httpapi/handlers_auth.goMe(~line 187-225). Track provider from the already-loaded user and add two fields to the JSON:
displayName := p.Subject
phone := ""
provider := "local"
if u, err := s.auth.GetUser(ctx, string(p.UserID)); err == nil {
if u.DisplayName != "" {
displayName = u.DisplayName
}
phone = u.Phone
provider = u.Provider
}
Then add to the writeJSON map (e.g. after "is_service": p.IsService,):
// Identity backend that owns this account ("local"/"dev"/"oidc"/"ldap"). A directory
// ('ldap') user has no local password — the profile UI hides change-password for them.
"provider": provider,
"is_directory": provider == "ldap",
(Commit this Go change together with the web changes in this task — one feature commit — or fold into T4's handlers_auth.go. Keep it in T5 so the whole "provider surfaced to the UI" story is one commit.)
- [ ] Step 2 —
/mehook (web). Editweb/src/api/me.ts: - Add to
interface Me:provider: stringandisDirectory: boolean. - Add to
type ApiMe:provider?: stringandis_directory?: boolean. - In the mapper (the returned object) add:
provider: r.provider || 'local',
isDirectory: !!r.is_directory,
- [ ] Step 3 — Hide change-password for directory users. Edit
web/src/features/profile/ProfilePage.tsxSecurity section (~line 117-128). Gate the change-password button on!me?.isDirectoryand show a hint when it IS a directory account:
{/* Security --------------------------------------------------------- */}
<section className="page__section">
<h2 className="page__section-title">{t('profile.security.title')}</h2>
<div className="profile-actions">
{!me?.isDirectory && (
<Button kind="tertiary" size="sm" renderIcon={Password} onClick={() => setChangePw(true)}>
{t('profile.security.changePassword')}
</Button>
)}
<Button kind="danger--tertiary" size="sm" renderIcon={Logout} onClick={() => setSignOutAll(true)}>
{t('profile.security.signOutAll')}
</Button>
</div>
{me?.isDirectory ? (
<p className="muted profile-actions__hint">{t('profile.security.directoryManaged')}</p>
) : (
<p className="muted profile-actions__hint">{t('profile.security.signOutAllHint')}</p>
)}
</section>
Add the directoryManaged key to web/src/features/profile/i18n.ts under profile.security (BOTH en + id):
- en: directoryManaged: 'Your password is managed by your directory (LDAP/AD). Sign out everywhere still works.',
- id: directoryManaged: 'Kata sandi Anda dikelola oleh direktori (LDAP/AD) organisasi. Keluar dari semua perangkat tetap tersedia.',
- [ ] Step 4 — Admin data hooks. Edit
web/src/features/admin/data.ts. Append (uses the typed clientapi/okalready imported at the top):
export interface LdapStatus {
enabled: boolean
url: string
mode: string
baseDn: string
lastBindOk: boolean
lastBindAt: string | null
}
export interface LdapGroupRole {
groupDn: string
roleId: string
}
export function useLdapStatus() {
return useQuery<LdapStatus>({
queryKey: ['ldap-status'],
queryFn: async () => {
const r = ok(await api.GET('/api/v1/admin/ldap/status', {}))
return {
enabled: !!r.enabled,
url: r.url ?? '',
mode: r.mode ?? '',
baseDn: r.base_dn ?? '',
lastBindOk: !!r.last_bind_ok,
lastBindAt: r.last_bind_at ?? null,
}
},
staleTime: 30_000,
})
}
export function useLdapGroupRoles() {
return useQuery<LdapGroupRole[]>({
queryKey: ['ldap-group-roles'],
queryFn: async () => {
const r = ok(await api.GET('/api/v1/admin/ldap/group-roles', {}))
return (r.group_roles ?? []).map((m) => ({ groupDn: m.group_dn, roleId: m.role_id }))
},
})
}
export function useUpsertLdapGroupRole() {
const qc = useQueryClient()
return useMutation({
mutationFn: (m: LdapGroupRole) =>
api.PUT('/api/v1/admin/ldap/group-roles', { body: { group_dn: m.groupDn, role_id: m.roleId } }).then(ok),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ldap-group-roles'] }),
})
}
export function useDeleteLdapGroupRole() {
const qc = useQueryClient()
return useMutation({
mutationFn: (groupDn: string) =>
api.DELETE('/api/v1/admin/ldap/group-roles', { params: { query: { group_dn: groupDn } } }).then(ok),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ldap-group-roles'] }),
})
}
(useQuery, useMutation, useQueryClient, api, ok are already imported at the top of admin/data.ts.)
- [ ] Step 5 — Tab component. Create
web/src/features/admin/LdapTab.tsx:
// Admin → Directory (LDAP): a read-only status card (enabled/URL/mode/base DN/last successful
// bind) plus a group-DN ↔ Obscura-role mapping table (add via a group-DN field + role dropdown,
// delete per row). Gated server-side on rbac.admin. When LDAP is not configured the card shows a
// disabled notice and the mapping editor stays usable (mappings can be prepared ahead of enabling).
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Button,
Dropdown,
InlineNotification,
StructuredListBody,
StructuredListCell,
StructuredListRow,
StructuredListWrapper,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TextInput,
Tag,
Tile,
} from '@carbon/react'
import { Add, TrashCan } from '@carbon/icons-react'
import { useRoles } from '@/api/rbac'
import {
useLdapStatus,
useLdapGroupRoles,
useUpsertLdapGroupRole,
useDeleteLdapGroupRole,
} from './data'
export function LdapTab() {
const { t } = useTranslation()
const status = useLdapStatus()
const mappings = useLdapGroupRoles()
const { data: roles = [] } = useRoles()
const upsert = useUpsertLdapGroupRole()
const del = useDeleteLdapGroupRole()
const [groupDn, setGroupDn] = useState('')
const [roleId, setRoleId] = useState('')
const [err, setErr] = useState(false)
const roleName = (id: string) => roles.find((r) => r.id === id)?.name ?? id
const canAdd = groupDn.trim() !== '' && roleId !== ''
const add = () => {
setErr(false)
upsert.mutate(
{ groupDn: groupDn.trim(), roleId },
{ onSuccess: () => { setGroupDn(''); setRoleId('') }, onError: () => setErr(true) },
)
}
const statusRow = (label: string, value: React.ReactNode) => (
<StructuredListRow>
<StructuredListCell>{label}</StructuredListCell>
<StructuredListCell className="mono">{value}</StructuredListCell>
</StructuredListRow>
)
return (
<div className="ai-tab">
<p className="page__lead muted">{t('admin.ldap.lead')}</p>
<Tile className="ai-tab__card">
<h3 className="ai-tab__card-title">{t('admin.ldap.status.title')}</h3>
{status.data && !status.data.enabled && (
<InlineNotification kind="info" lowContrast hideCloseButton title={t('admin.ldap.status.disabled')} subtitle={t('admin.ldap.status.disabledHint')} />
)}
<StructuredListWrapper isCondensed ariaLabel={t('admin.ldap.status.title')}>
<StructuredListBody>
{statusRow(
t('admin.ldap.status.state'),
status.data?.enabled ? <Tag type="green" size="sm">{t('admin.ldap.status.enabled')}</Tag> : <Tag type="gray" size="sm">{t('admin.ldap.status.off')}</Tag>,
)}
{statusRow(t('admin.ldap.status.url'), status.data?.url || '—')}
{statusRow(t('admin.ldap.status.mode'), status.data?.mode || '—')}
{statusRow(t('admin.ldap.status.baseDn'), status.data?.baseDn || '—')}
{statusRow(
t('admin.ldap.status.lastBind'),
status.data?.lastBindAt
? `${status.data.lastBindOk ? '✓' : '✗'} ${status.data.lastBindAt}`
: t('admin.ldap.status.never'),
)}
</StructuredListBody>
</StructuredListWrapper>
</Tile>
<Tile className="ai-tab__card">
<h3 className="ai-tab__card-title">{t('admin.ldap.map.title')}</h3>
<p className="muted ai-tab__hint">{t('admin.ldap.map.hint')}</p>
<div className="ldap-map__add">
<TextInput
id="ldap-group-dn"
labelText={t('admin.ldap.map.groupDn')}
placeholder="cn=obscura-admins,ou=groups,dc=corp,dc=example,dc=id"
value={groupDn}
onChange={(e) => setGroupDn(e.target.value)}
/>
<Dropdown
id="ldap-role"
titleText={t('admin.ldap.map.role')}
label={t('admin.ldap.map.rolePlaceholder')}
items={roles}
itemToString={(r) => (r ? r.name : '')}
selectedItem={roles.find((r) => r.id === roleId) ?? null}
onChange={({ selectedItem }) => setRoleId(selectedItem?.id ?? '')}
/>
<Button size="md" renderIcon={Add} onClick={add} disabled={!canAdd || upsert.isPending}>
{t('admin.ldap.map.add')}
</Button>
</div>
{err && <InlineNotification kind="error" lowContrast title={t('admin.saveError')} onCloseButtonClick={() => setErr(false)} className="ai-tab__note" />}
{(mappings.data ?? []).length === 0 ? (
<p className="muted">{t('admin.ldap.map.empty')}</p>
) : (
<Table size="sm" className="ai-tab__table">
<TableHead>
<TableRow>
<TableHeader>{t('admin.ldap.map.groupDn')}</TableHeader>
<TableHeader>{t('admin.ldap.map.role')}</TableHeader>
<TableHeader>{t('admin.ldap.map.actions')}</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{(mappings.data ?? []).map((m) => (
<TableRow key={m.groupDn}>
<TableCell className="mono">{m.groupDn}</TableCell>
<TableCell>{roleName(m.roleId)}</TableCell>
<TableCell>
<Button
kind="ghost"
size="sm"
hasIconOnly
iconDescription={t('admin.ldap.map.delete')}
renderIcon={TrashCan}
onClick={() => del.mutate(m.groupDn)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Tile>
</div>
)
}
Add a small style block to web/src/styles/app.css:
/* LDAP group→role mapping add-row (admin) */
.ldap-map__add { display: flex; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.ldap-map__add > *:first-child { flex: 1 1 20rem; }
- [ ] Step 6 — Register the tab. Edit
web/src/features/admin/AdminPage.tsx: import { LdapTab } from './LdapTab'(with the other tab imports,~line 46).- Add the tab label after the AI tab (
~line 115):<Tab>{t('admin.tabs.ldap')}</Tab>. -
Add its panel as the LAST
<TabPanel>(matching order):<TabPanel><LdapTab /></TabPanel>. -
[ ] Step 7 — i18n. Edit
web/src/features/admin/i18n.ts. Addtabs.ldapand anldapgroup in BOTHenandid(identical shape):
en — add ldap: 'Directory (LDAP)' to tabs, and:
ldap: {
lead: 'Connect Active Directory / LDAP so staff sign in with their directory credentials. Users are created on first login and receive Obscura roles from their directory groups. Configure the connection via the server’s LDAP_* environment variables (see docs/LDAP.md).',
status: {
title: 'Connection',
state: 'Status',
enabled: 'Enabled',
off: 'Disabled',
disabled: 'Directory login is not enabled',
disabledHint: 'Set LDAP_ENABLED=true and the LDAP_* connection variables on the server, then restart. Mappings below can be prepared in advance.',
url: 'Server URL',
mode: 'Mode',
baseDn: 'Base DN',
lastBind: 'Last successful bind',
never: 'Never',
},
map: {
title: 'Group → role mapping',
hint: 'Map a directory group (by its full DN) to an Obscura role. On each directory login, members gain the mapped roles and losing the group removes them — manually-assigned roles are never touched.',
groupDn: 'Group DN',
role: 'Role',
rolePlaceholder: 'Select a role',
add: 'Add mapping',
actions: 'Actions',
delete: 'Delete mapping',
empty: 'No mappings yet.',
},
},
id — add ldap: 'Direktori (LDAP)' to tabs, and:
ldap: {
lead: 'Hubungkan Active Directory / LDAP agar staf masuk dengan kredensial direktori mereka. Pengguna dibuat saat login pertama dan memperoleh peran Obscura dari grup direktori. Konfigurasikan koneksi lewat variabel lingkungan LDAP_* di server (lihat docs/LDAP.md).',
status: {
title: 'Koneksi',
state: 'Status',
enabled: 'Aktif',
off: 'Nonaktif',
disabled: 'Login direktori belum diaktifkan',
disabledHint: 'Setel LDAP_ENABLED=true dan variabel koneksi LDAP_* di server, lalu mulai ulang. Pemetaan di bawah dapat disiapkan lebih dulu.',
url: 'URL server',
mode: 'Mode',
baseDn: 'Base DN',
lastBind: 'Bind sukses terakhir',
never: 'Belum pernah',
},
map: {
title: 'Pemetaan grup → peran',
hint: 'Petakan grup direktori (berdasarkan DN lengkapnya) ke peran Obscura. Pada setiap login direktori, anggota memperoleh peran yang dipetakan dan kehilangan grup akan menghapusnya — peran yang ditetapkan manual tidak pernah diubah.',
groupDn: 'DN grup',
role: 'Peran',
rolePlaceholder: 'Pilih peran',
add: 'Tambah pemetaan',
actions: 'Aksi',
delete: 'Hapus pemetaan',
empty: 'Belum ada pemetaan.',
},
},
- [ ] Step 8 — Verify:
cd web && npx tsc --noEmit && npx vite build;cd go && go build ./... && go vet ./.... If smart quotes in an i18n file broke tsc, rewrite the whole file with Write. - [ ] Step 9 — Commit:
git add go/internal/httpapi/handlers_auth.go web/src/api/me.ts web/src/features/profile/ProfilePage.tsx web/src/features/profile/i18n.ts web/src/features/admin/data.ts web/src/features/admin/LdapTab.tsx web/src/features/admin/AdminPage.tsx web/src/features/admin/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Admin Directory (LDAP) tab + /me provider + hide change-password for ldap users"
Task 6: Compose ldap profile (osixia/openldap + seed) + operator guide
Files: deploy/docker-compose.yml, deploy/ldap/01-memberof-overlay.ldif (new), deploy/ldap/02-seed.ldif (new), docs/LDAP.md (new).
memberOf decision (be honest about osixia/openldap:1.5.0): real AD populates memberOf natively, so the default LDAP_ATTR_GROUPS=memberOf is correct-by-construction against AD. OpenLDAP does not populate memberOf unless the memberof overlay is loaded. Chosen approach for the rig: enable the memberof + refint overlays via an osixia bootstrap config LDIF, and seed groupOfNames groups so memberOf is auto-maintained — this exercises the exact default config end-to-end. osixia/openldap:1.5.0 (OpenLDAP 2.6.x) ships both overlays; osixia applies custom LDIFs under bootstrap/ldif/custom/ at first init. Honest caveat + fallback (verified in T7): if memberOf comes back empty after seeding (the overlay config did not apply against cn=config on this image build), fall back to seeding each user with an explicit group-DN-list attribute and setting LDAP_ATTR_GROUPS to it — the authenticator's attr→groups→role path is identical. Also note: OpenLDAP has no objectClass=user/sAMAccountName (those are AD-only), so the rig overrides LDAP_USER_FILTER to an inetOrgPerson-shaped filter (uid). This is documented in T7 + docs/LDAP.md.
- [ ] Step 1 — Overlay config LDIF. Create
deploy/ldap/01-memberof-overlay.ldif:
# Enable the memberof + refint overlays so a groupOfNames membership is reflected back onto the
# member entry's operational memberOf attribute (what LDAP_ATTR_GROUPS=memberOf reads). Applied
# by osixia at first init. cn=config entries; harmless to re-run (osixia skips on re-init).
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
-
add: olcModuleLoad
olcModuleLoad: refint
dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config
changetype: add
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
dn: olcOverlay=refint,olcDatabase={1}mdb,cn=config
changetype: add
objectClass: olcConfig
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
objectClass: top
olcOverlay: refint
olcRefintAttributes: memberof member manager owner
- [ ] Step 2 — Seed users + groups LDIF. Create
deploy/ldap/02-seed.ldif(domaindc=obscura,dc=local; users underou=people, groups underou=groups; two users, two groups; passwordsldappass1/ldappass2):
dn: ou=people,dc=obscura,dc=local
objectClass: organizationalUnit
ou: people
dn: ou=groups,dc=obscura,dc=local
objectClass: organizationalUnit
ou: groups
dn: uid=ldapuser1,ou=people,dc=obscura,dc=local
objectClass: inetOrgPerson
objectClass: top
uid: ldapuser1
cn: LDAP User One
sn: One
displayName: LDAP User One
mail: ldapuser1@obscura.local
userPassword: ldappass1
dn: uid=ldapuser2,ou=people,dc=obscura,dc=local
objectClass: inetOrgPerson
objectClass: top
uid: ldapuser2
cn: LDAP User Two
sn: Two
displayName: LDAP User Two
mail: ldapuser2@obscura.local
userPassword: ldappass2
dn: cn=obscura-admins,ou=groups,dc=obscura,dc=local
objectClass: groupOfNames
cn: obscura-admins
member: uid=ldapuser1,ou=people,dc=obscura,dc=local
dn: cn=obscura-staff,ou=groups,dc=obscura,dc=local
objectClass: groupOfNames
cn: obscura-staff
member: uid=ldapuser2,ou=people,dc=obscura,dc=local
- [ ] Step 3 — Compose service. Edit
deploy/docker-compose.yml. Add next to thekeycloak/casdooropt-in IdPs (~line 216):
# --- LDAP directory for end-to-end auth testing (opt-in via the "ldap" profile) ---
# docker compose -f deploy/docker-compose.yml --profile ldap up -d ldap
# Seeds two users (ldapuser1@obscura.local / ldappass1, ldapuser2@obscura.local / ldappass2)
# and two groups (obscura-admins ⊇ user1, obscura-staff ⊇ user2). The memberof overlay makes
# each user's memberOf reflect their group. NOT part of the default stack; the demo obscura
# keeps LDAP disabled. See docs/LDAP.md.
ldap:
image: osixia/openldap:1.5.0
profiles: ["ldap"]
command: ["--copy-service"]
environment:
LDAP_ORGANISATION: "Obscura Demo"
LDAP_DOMAIN: "obscura.local"
LDAP_ADMIN_PASSWORD: "admin"
volumes:
# Custom bootstrap: overlay config + seed data, applied at first init (in filename order).
- ./ldap/01-memberof-overlay.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/01-memberof-overlay.ldif:ro
- ./ldap/02-seed.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/02-seed.ldif:ro
ports: ["389:389", "636:636"]
(--copy-service makes osixia process the mounted custom LDIFs. If the overlay config LDIF causes a first-boot failure on this image build, the honest fallback in T7 removes 01-memberof-overlay.ldif from the mounts and drives groups via a direct attribute — see the memberOf decision note above.)
- [ ] Step 4 — Operator guide. Create
docs/LDAP.md— a customer-facing operator guide. Contents (write in full prose; the skeleton below is the required structure, not placeholders): - What Obscura needs from your AD/LDAP administrator (a checklist): an
ldaps://host:636URL (orldap://host:389+ StartTLS), a read-only service account DN + password for the search bind, the base DN to search under, confirmation that themailattribute is populated (or which attribute holds the login email), and the group DNs you want mapped to Obscura roles. - Environment block (copy-paste, AD defaults):
LDAP_ENABLED=true LDAP_URL=ldaps://dc1.corp.example.id:636 LDAP_BIND_DN=CN=obscura-svc,OU=Service Accounts,DC=corp,DC=example,DC=id LDAP_BIND_PASSWORD=... # via --env-file / secret, not committed LDAP_BASE_DN=DC=corp,DC=example,DC=id LDAP_USER_FILTER=(&(objectClass=user)(|(mail=%s)(sAMAccountName=%s))) LDAP_ATTR_EMAIL=mail LDAP_ATTR_NAME=displayName LDAP_ATTR_GROUPS=memberOf LDAP_MODE=mixed # or "only" to disable local passwords (admins keep break-glass) # LDAP_CA_FILE=/run/secrets/ldap-ca.pem # for a private CA # LDAP_START_TLS=true # only with an ldap:// URL (not ldaps://) # LDAP_INSECURE_SKIP_VERIFY=true # TESTING ONLY - How login works (transparent same form; JIT provisioning; group→role mapping in Admin → Directory (LDAP);
onlymode + break-glass; change-password lives in AD). - The OpenLDAP test rig (the
--profile ldaprecipe, theLDAP_USER_FILTERoverride for inetOrgPerson/uid, the seed creds). -
Troubleshooting table — each distinct failure and what it means:
Symptom Likely cause Boot fails: "LDAP_URL/LDAP_BASE_DN required" / "mutually exclusive" fail-closed config: fix the env Every LDAP login → invalid credentials, WARN "dial"/"starttls" can't reach the server / TLS handshake (URL, firewall, CA, StartTLS-vs-ldaps) WARN "service bind" wrong LDAP_BIND_DN/LDAP_BIND_PASSWORDWARN "expected exactly 1 result, got 0/2" LDAP_USER_FILTER/LDAP_BASE_DNwrong, or the login value isn't inmail/sAMAccountNameWARN "user bind" correct user found but the submitted password was wrong (normal for a typo) Login works but no roles no memberOfreturned (LDAP_ATTR_GROUPS), or no group→role mapping configured -
[ ] Step 5 — Upload the rendered guide (repo CLAUDE.md requirement for new
.md):curl -F "file=@docs/LDAP.md" https://x056.think.val.id/uploadand give the user the returned URL. - [ ] Step 6 — Commit:
git add deploy/docker-compose.yml deploy/ldap/01-memberof-overlay.ldif deploy/ldap/02-seed.ldif docs/LDAP.md
git commit -m "docs+deploy: LDAP operator guide + osixia/openldap e2e compose profile"
Task 7: Deploy + curl e2e (controller drives this personally — NOT a subagent)
Least-demo-risk instance approach (chosen + why): run a second, throwaway obscura instance on host port :18099 off the freshly-built image, joined to the compose network, sharing the demo Postgres + MinIO but with its OWN env (LDAP_ENABLED=true + the LDAP_* block). The main demo obscura service's env is never touched (it keeps LDAP disabled). This is the exact shape the Peruri e2e used (2026-07-02, :18099, shared demo DB, demo untouched — memory obscura-peruri-emeterai.md), so it is proven low-risk. The rig writes into the shared demo DB (JIT users + grants), so cleanup deletes those rows.
Prereqs: T1–T6 committed on main and building; the main stack deployed (Global Constraints deploy line) with the 5 modules asserted; the ldap profile up (docker compose -f deploy/docker-compose.yml --profile ldap up -d ldap).
-
[ ] Step 1 — Build + baseline. Deploy the main stack (
--build obscura web), dev-logindirector@obscura.local(:38080) →TOKEN,GET /api/v1/me→ assertenabled_modules == [ai, correspondence, esign, semantic, watermarking]. Bring up theldapprofile; verify the seed (ldapsearch -x -H ldap://localhost:389 -D "cn=admin,dc=obscura,dc=local" -w admin -b "dc=obscura,dc=local"shows both users + groups) and thatmemberOfis populated onuid=ldapuser1(if not → apply the memberOf fallback from T6 before proceeding, and note it in the report). -
[ ] Step 2 — Launch the LDAP-enabled rig instance on
:18099, on the compose network, sharing the demo DB/MinIO. Determine the compose network name (docker network ls | grep obscura— typicallydeploy_default). Run (single line; the OpenLDAP rig overridesLDAP_USER_FILTERto the inetOrgPerson shape and pointsLDAP_URLat theldapservice):
docker run -d --name obscura-ldap-e2e --network deploy_default \
-e OBSCURA_ENV=development -e OBSCURA_ROLE=all \
-e DATABASE_URL="postgres://obscura:obscura@postgres:5432/obscura?sslmode=disable" \
-e MIGRATE_ON_BOOT=true \
-e S3_ENDPOINT=minio:9000 -e S3_BUCKET=obscura -e S3_ACCESS_KEY=obscura -e S3_SECRET_KEY=obscura-dev-secret -e S3_USE_SSL=false \
-e SMTP_HOST=mailpit -e SMTP_PORT=1025 -e SMTP_FROM=obscura@obscura.local \
-e LDAP_ENABLED=true \
-e LDAP_URL=ldap://ldap:389 \
-e LDAP_BIND_DN="cn=admin,dc=obscura,dc=local" -e LDAP_BIND_PASSWORD=admin \
-e LDAP_BASE_DN="dc=obscura,dc=local" \
-e 'LDAP_USER_FILTER=(&(objectClass=inetOrgPerson)(|(mail=%s)(uid=%s)))' \
-e LDAP_ATTR_EMAIL=mail -e LDAP_ATTR_NAME=displayName -e LDAP_ATTR_GROUPS=memberOf \
-e LDAP_MODE=mixed \
-p 18099:8080 <the image tag built for the obscura service>
(Find the image tag with docker compose -f deploy/docker-compose.yml images obscura, or docker inspect deploy-obscura-1 --format '{{.Config.Image}}'.) Wait for health; hit http://localhost:18099/api/v1/me unauth → 401 confirms it's serving.
- [ ] Step 3 — Login-flow matrix (all against
:18099unless noted). First map a group→role: as the director on the MAIN instance (:38080),PUT /api/v1/admin/ldap/group-roles {group_dn:"cn=obscura-admins,ou=groups,dc=obscura,dc=local", role_id:<an existing role id from GET /api/v1/roles>}(mappings live in the shared DB, so the rig sees them). Then: - (a) First login = JIT:
POST /api/v1/auth/login {email:"ldapuser1@obscura.local", password:"ldappass1"}→ 200 + token. Assert in Postgres: ausersrow withprovider='ldap'andpassword_hash IS NULL;GET /api/v1/me(that token) showsprovider:"ldap",is_directory:true. - (b) Group→role grant: after (a), assert the mapped role is bound (
GET /api/v1/meroles include it;ldap_role_grantshas the row). - (c) Wrong password:
login {ldapuser1@obscura.local, "nope"}→ generic invalid-credentials (same body as a bad local login). - (d) Unknown user:
login {nobody@nowhere, "x"}→ same generic invalid-credentials. - (e) Mapping removal → revoke on next login:
DELETE /api/v1/admin/ldap/group-roles?group_dn=cn=obscura-admins,...; log in as ldapuser1 again → the previously-mapped role is gone (ldap_role_grantsrow removed); re-add the mapping. - (f) Manual role untouched: bind some OTHER role to the ldapuser1 user directly (admin API), log in again → that manual role survives reconciliation (no
ldap_role_grantsrow for it). - (g) TOTP-enrolled ldap user: enrol+confirm TOTP for an ldap user, confirm the TOTP challenge still applies (unchanged path).
- (h) Change-password rejected:
POST /api/v1/me/passwordas an ldap user → 400auth.ldap.managed_by_directory. -
(i)
LDAP_MODE=only: restart the rig with-e LDAP_MODE=only. Assert: a local-provider user's local password login is rejected (create/pick a local user; e.g.PasswordLoginfor a seeded@obscura.localdemo account) UNLESS they hold admin (director/admin → break-glass works); an ldap user still logs in; dev-login on the MAIN instance is unaffected. -
[ ] Step 4 — Cleanup + demo-intact assert. Stop + remove the rig (
docker rm -f obscura-ldap-e2e) and the ldap profile (docker compose -f deploy/docker-compose.yml --profile ldap downorstop ldap). Delete e2e residue from the shared DB: the JIT'dprovider='ldap'users (cascade removes their sessions/totp/ldap_role_grants), anyldap_group_rolesrows created, and any manual test binding.SELECT count(*) FROM users WHERE provider='ldap'→ 0;SELECT count(*) FROM ldap_group_roles/ldap_role_grants→ 0. Re-assert the MAIN demo instance:GET /api/v1/me→ 5 modules intact, and a normal dev-login/local login still works. -
[ ] Step 5 — Final commit of any e2e-driven fixes; report the commit list + which matrix assertions passed + the memberOf outcome (overlay worked, or fallback used).
Self-review notes (done at plan time)
- Spec coverage:
- §Login flow (try local first; unknown/verify-failure → LDAP; but a known local-provider user never ldap-falls-through; JIT
provider='ldap'no hash; local user with same email wins locally) →LoginWithDirectory(T2 Step 5d) with the exact branch onexisting.Provider != "ldap"vs unknown/ldap.PasswordLogincalls it (T2 Step 6). TOTP untouched (handler layer). Change/forgot-password rejected for ldap → guard inUpdatePassword(T2 Step 5e); there is no forgot-password endpoint (onlyPOST /me/password), so the one guard covers it. - §Config table (all 13
LDAP_*vars, exact defaults incl. the AD filter + mail/displayName/memberOf + mixed default) + fail-closed (missing URL/base DN, ldaps+StartTLS, bad mode) →LDAPConfig+validate()(T1 Step 2). - §Implementation shape (
auth/adapters/ldap.goLDAPAuthenticator: dial ldap/ldaps, StartTLS, CA pool, InsecureSkipVerify logged, service search bind,ldap.EscapeFilter, exactly-1 result, user bind, attr+group fetch, conn closed, timeouts) → T1 Step 3. One new depgo-ldap/ldap/v3(T1 Step 1). Constructed in wire.go, nil when disabled (T2 Step 7).DirectoryAuthenticatorport +LoginWithDirectory(T2 Steps 3,5). JIT reusesUpsertUserByIdentityshape (T2 Step 5d). - §Group→role mapping (migration
ldap_group_roles+ldap_role_grantsPK(user,role); read memberOf, case-insensitive DN match, reconcile grant/revoke via the rbac binding store, manual roles untouched, errors non-fatal) → T3 (GroupRoleService.Reconcile) +00083. Admin section (status card + mapping CRUD,GET /admin/ldap/status,GET/PUT/DELETE /admin/ldap/group-roles,rbac.admingate) → T4 + T5. Positions/org tree stay Obscura-managed (only ROLES mapped) — reconciler binds roles to the USER subject only. - §Error handling table (unreachable → local unaffected + generic + WARN; 0/>1 → generic; group fetch fail → login succeeds, reconcile skipped, WARN; config invalid → boot fail; ldap change-password → 400) → all realized: generic
auth.login.invalideverywhere (T2 Step 5d), reconcile error logged non-fatal (T2 Step 5d), fail-closed (T1 Step 2), 400 guard (T2 Step 5e). - §Testing matrix → T7 (JIT+provider+no-hash, wrong pw, unknown, grant, removal→revoke, manual untouched,
onlymode incl. break-glass + ldap-still-works, TOTP, change-password rejected, demo intact). - Type consistency:
domain.User.Provider(T2 Step 2) ↔users.providercolumn (T2 Step 1) ↔userColumns/scanUser(T2 Step 4) ↔ surfaced by/me(T5 Step 1) ↔ webMe.provider/isDirectory(T5 Step 2).DirectoryIdentity/DirectoryStatus/the three ports defined once inauth/app/directory.go(T2 Step 3), implemented byLDAPAuthenticator(T1) +GroupRoleService(T3) +ldapAdminChecker(T2 Step 7).GroupRoleMapping/RoleGrant/DirectoryGrantStore/RoleBinderinauth/app/ldap.go(T3 Step 2), implLDAPGrantStore(T3 Step 3), consumed by handlers (T4) ↔LdapGroupRole/LdapStatusOpenAPI schemas (T4 Steps 4-5) → regeneratedschema.ts→useLdapStatus/useLdapGroupRoles(T5 Step 4). rbac*StoresatisfiesRoleBinder(BindRole/UnbindRole/RefreshEffectivePermsnames verified). - Placeholder scan: no TBD/TODO — full Go for config+validate, the authenticator, the orchestrator, the store, the reconciler, all four handlers; full TSX for the tab; complete LDIFs + compose service. The only judgment-flagged items are (i) the osixia memberOf overlay (T6 gives the concrete config LDIF + an honest, actionable fallback verified in T7) and (ii) the T7 rig image tag (resolved at run time via
docker compose images). - Build-order note (called out in tasks): T1's
ldap.go, T2'sdirectory.go/service.go, and T3's ports/store/wire hunk are mutually dependent (the adapter importsapp.DirectoryIdentity; wire.go references T3 constructors). The tasks instruct to sequence T1→T2→T3 and only assertgo buildgreen at the T3 commit (each commit stages only files that keep the tree buildable — the wire.go hunk ships with T3). Verify runsgo build ./... && go vet ./...after T3 and again after T4/T5. - Known judgment calls / spec assumptions (see report):
1. Addedusers.provider(migration00082) rather than deriving provider fromuser_identitieson every read — the spec's model is literally "decided byusers.provider", and a column gives clean one-read branching for login + change-password +/me. Backfilled from the primary identity idp;UpsertUserByIdentitystamps it (provider = idp, so existing dev/local/oidc users becomedev/local/oidc, ldap usersldap). Two migrations (00082+00083) vs the "expect 00082" hint.
2. JIT subject = the user's DN (stable, from the search result) — not email — matching "bind as the found user DN"; email is the JIT display/contact. (objectGUID would be even more stable across OU moves; deferred with the rest of attribute-sync, per §Out-of-scope.)
3. Break-glass admin = the wildcard role NAMED"admin", resolved the same way/medoes (user + position bindings) vialdapAdminChecker(wire.go). Only consulted inLDAP_MODE=only. dev-login is a separate dev-only endpoint and is inherently unaffected.
4. Group-role mapping + reconciliation owned by the auth context (spec allowed auth or rbac); it drives rbac purely through the narrowRoleBinderport (no rbac import from auth).
5. memberOf for the OpenLDAP rig: primary = enable thememberof/refintoverlays (real memberOf, faithful to the AD default); honest fallback documented if the overlay config doesn't apply on the image build. The rig also overridesLDAP_USER_FILTERto inetOrgPerson/uidbecause OpenLDAP lacks AD'sobjectClass=user/sAMAccountName.
6. T7 instance approach = a throwawaydocker runobscura on:18099sharing the demo Postgres/MinIO with its own LDAP env (Peruri-precedent), so the main demo instance's env is never touched; cleanup deletes JIT users + ldap rows from the shared DB.