Skip to content

certificates: Allow LDAP enrollment - #1401

Closed
denisonbarbosa wants to merge 49 commits into
mainfrom
ldap-cert-enrollment
Closed

certificates: Allow LDAP enrollment#1401
denisonbarbosa wants to merge 49 commits into
mainfrom
ldap-cert-enrollment

Conversation

@denisonbarbosa

@denisonbarbosa denisonbarbosa commented Jun 1, 2026

Copy link
Copy Markdown
Member

Certificate enrollment has been a point of struggle for adsys' users for a while now. Although it works, configuring CEPCES servers on Windows is not easy and comes with many pitfalls that can block the enrollment process. Also, CEPCES was designed to allow enrollment for machines outside of the domain, which is not the case for adsys.

This PR adds a native Go enrollment backend that talks to AD CS directly — LDAP for discovery, MS-ICPR over RPC for issuance — so autoenrollment needs no setup beyond the AD CS service itself. The legacy CEPCES path is kept intact and selectable.

UDENG-11047

What changes

Native enrollment backend (internal/policies/certificate/)

  • Machine bind to AD over LDAP with Kerberos/GSSAPI, StartTLS, and channel binding.
  • CA and template discovery straight from the directory, honouring template ACLs and the AutoEnroll right.
  • CSR submission via MS-ICPR, including manager-approval/pending requests and retry.
  • On-disk state in JSON (replacing the Samba TDB cache), CA chain installation into the system trust store, and in-daemon renewal.

Backend selection

  • New certificate_enrollment setting (ldap | cepces), plumbed through the daemon CLI, service options and the policy manager, and validated at every layer.
  • The code-level default stays cepces, so existing installations are unaffected. Fresh installs opt into ldap through postinst.
  • Switching an enrolled machine from cepces to ldap is transactional: certmonger stops tracking the old material before the TDB is removed, so the two backends never manage the same files at once.

Certificate management CLI — new adsysctl certificate command group: list, status, renew, remove, verify (with --online CRL checking), cas, and templates. Backed by seven new gRPC methods and a CertHealth enum (healthy, due_renewal, expired, missing, key_mismatch, unparseable, not_yet_valid).

Packaging

  • python3-cepces moves from Recommends to Suggests: adsys is in main and cepces is still in universe, so a Recommends would create a component mismatch without a MIR.
  • ca-certificates added to Depends (the trust store is now written directly).
  • Build-Depends raised to Go 1.25 to match go.mod; the Jammy/Noble E2E backport patches were refreshed to match, with a new test that fails if debian/control or debian/rules drift away from them again.
  • postinst writes /etc/adsys.yaml on first install only; postrm removes it on purge. This is deliberately not conffile-managed — see the inline comment for the upgrade/reinstall reasoning.

Security notes

The threat model got most of the review attention, so calling out the decisions explicitly:

  • Bootstrap trust. StartTLS validates the DC certificate against the system store plus adsys-managed CAs before the GSSAPI bind, and channel binding is enforced. A discovered self-signed AD CS root is trust-on-first-use, resting on that authenticated channel — the same assumption Windows autoenrollment makes about the directory. A non-self-signed CA must chain to an already-trusted anchor.
  • Chain binding. Enrollments are bound to the exact trust chain they were issued under; a CA whose root fails to install or verify is skipped with prior state preserved.
  • Atomicity. Key and certificate are renewed as a pair, written through O_EXCL temp files and renamed. Reconciliation is crash-safe, and a failed renewal keeps the still-valid certificate instead of letting orphan cleanup delete it.
  • Revocation. verify --online only trusts a CRL signed by the certificate's issuer chain and inside its validity window; anything else is reported as indeterminate rather than clean.
  • Lock discipline. The always-authorized templates RPC holds no global lock, and the requested server is only a selector over domain-discovered CAs — it is never dialed.
  • The ADSYS_TESTS_WITHOUT_KERBEROS escape hatch is gated behind testing.Testing() and cannot take effect in production builds.

Testing

Unit and integration coverage for the new package, plus E2E scenarios that pin the backend and exercise LDAP enrollment (health, key match, chain, trust store, renewal), CEPCES compatibility, and the CEPCES→LDAP migration.

Notes for reviewers

  • CertAutoEnrollScript and adsysctl policy cert-autoenroll-script are unchanged — an earlier revision altered them and that was reverted.
  • The adsys-certsubmit helper explored earlier was dropped rather than shipped.
  • Suggested reading order: constsldap.go/gssapi.goenroll.gostate.go/lifecycle.gomanage.go → packaging.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new native (Go) certificate auto-enrollment path that discovers AD CS enrollment data via LDAP and submits CSRs via MS-ICPR (RPC), while keeping the existing CEPCES/Python path for backward compatibility and wiring enrollment-method selection through daemon/service configuration and packaging/docs.

Changes:

  • Add LDAP discovery, Kerberos GSSAPI bind support, MS-ICPR CSR submission, and on-disk enrollment state/trust-store management in the certificate policy manager.
  • Add configuration plumbing (certificate_enrollment) across daemon/service/policy manager, plus updated tests and integration fixtures.
  • Update docs and Debian packaging to reflect the new enrollment method options and remove legacy script-dump flows.

Reviewed changes

Copilot reviewed 64 out of 99 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
internal/policies/manager.go Plumbs certificate manager injection and enrollment method into policy manager construction.
internal/policies/manager_test.go Updates policy-manager tests to use the new certificate manager wiring and LDAP enrollment mocks.
internal/policies/certificate/trust.go Adds root CA installation/symlink creation and trust-store update helpers.
internal/policies/certificate/state.go Adds JSON state persistence for enrollment (replacing Samba TDB cache).
internal/policies/certificate/ldap.go Implements LDAP discovery and Kerberos (GSSAPI) bind support and ccache lookup.
internal/policies/certificate/ldap_test.go Adds unit tests for LDAP discovery/template lookup helpers.
internal/policies/certificate/gssapi.go Adds a Go LDAP GSSAPI client implementation for Kerberos binds.
internal/policies/certificate/gssapi_test.go Adds unit tests for GSSAPI token unwrap/wrap behavior and RRC handling.
internal/policies/certificate/enroll.go Implements MS-ICPR CSR submission and key/CSR generation helpers.
internal/policies/certificate/enroll_test.go Adds tests for RPC credential creation from Kerberos ccaches.
internal/policies/certificate/certificate.go Introduces dual enrollment methods (ldap/cepces) and LDAP enrollment flow (state + cert/key writing).
internal/policies/certificate/certificate_test.go Reworks certificate policy tests to exercise LDAP enrollment path and state cleanup.
internal/policies/certificate/testdata/TestApplyPolicy/golden/computer,_no_entries,_samba_cache_present Removes golden output tied to legacy Python autoenroll script behavior.
internal/policies/certificate/testdata/TestApplyPolicy/golden/computer,_configured_to_unenroll Removes golden output tied to legacy Python autoenroll script behavior.
internal/policies/certificate/testdata/TestApplyPolicy/golden/computer,_configured_to_enroll,_advanced_configuration Removes golden output tied to legacy Python autoenroll script behavior.
internal/policies/certificate/testdata/TestApplyPolicy/golden/computer,_configured_to_enroll Removes golden output tied to legacy Python autoenroll script behavior.
internal/consts/consts.go Adds constants for enrollment method selection and defaults.
internal/adsysservice/policy.go Changes CertAutoEnrollScript RPC response to informational text (no longer returns embedded script).
internal/adsysservice/adsysservice.go Adds service option to pass certificate_enrollment into the policy manager.
internal/ad/ad.go Adds test-only env var to disable Kerberos in integration environments without a KDC.
cmd/adsysd/daemon/daemon.go Adds CLI/config flag plumbing for certificate_enrollment.
cmd/adsysd/client/policy.go Removes CLI command that dumped the legacy cert-autoenroll script.
cmd/adsys-certsubmit/main.go Adds a new helper binary intended for certmonger-style operations (SUBMIT/IDENTIFY/GET-SUPPORTED-TEMPLATES).
cmd/adsysd/integration_tests/adsysctl_policy_test.go Splits certmonger/cepces missing-binary scenarios and removes script-dump tests; improves NSS wrapper handling.
cmd/adsysd/integration_tests/adsys_test.go Ensures integration tests run with Kerberos disabled and augments passwd/group for container mocks.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/systemd/system/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/users/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/.ready Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/startup Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/empty-subfolder/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/final-machine-script.sh Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/other-script-user-logon Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/otherfolder/script-user-logoff Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/script-machine-shutdown Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/script-machine-startup Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/script-user-logon Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/subfolder/other-script Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/unreferenced-data Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/run/machine/scripts/scripts/unreferenced-script Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/sudoers.d/99-adsys-privilege-enforcement Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/polkit-1/localauthority.conf.d/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/polkit-1/rules.d/00-adsys-privilege-enforcement.rules Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/apparmor.d/adsys/machine/usr.bin.bar Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/apparmor.d/adsys/machine/usr.bin.foo Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/apparmor.d/adsys/machine/nested/usr.bin.baz Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/apparmor.d/adsys/users/adsystestuser@example.com Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/dconf/profile/gdm Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/dconf/db/gdm.d/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/dconf/db/gdm.d/locks/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/dconf/db/machine.d/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/dconf/db/machine.d/locks/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/lib/private Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_certmonger_is_not_available/lib/samba/cert_gpo_state_HOST.tdb Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/systemd/system/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/users/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/.ready Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/startup Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/empty-subfolder/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/final-machine-script.sh Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/other-script-user-logon Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/otherfolder/script-user-logoff Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/script-machine-shutdown Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/script-machine-startup Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/script-user-logon Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/subfolder/other-script Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/unreferenced-data Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/run/machine/scripts/scripts/unreferenced-script Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/sudoers.d/99-adsys-privilege-enforcement Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/polkit-1/localauthority.conf.d/.empty Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/polkit-1/rules.d/00-adsys-privilege-enforcement.rules Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/apparmor.d/adsys/machine/usr.bin.bar Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/apparmor.d/adsys/machine/usr.bin.foo Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/apparmor.d/adsys/machine/nested/usr.bin.baz Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/apparmor.d/adsys/users/adsystestuser@example.com Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/dconf/profile/gdm Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/dconf/db/gdm.d/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/dconf/db/gdm.d/locks/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/dconf/db/machine.d/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/dconf/db/machine.d/locks/adsys Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/lib/private Updates golden fixtures for integration test scenario.
cmd/adsysd/integration_tests/testdata/TestPolicyUpdate/golden/does_not_error_when_cepces_is_not_available/lib/samba/cert_gpo_state_HOST.tdb Updates golden fixtures for integration test scenario.
e2e/cmd/run_tests/01_provision_client/main.go Drops python3-cepces install in e2e provisioning.
docs/reference/external-links.md Removes CEP/CES external links section and normalizes formatting.
docs/index.md Adjusts formatting; still references CEP/CES link anchor (now removed elsewhere).
docs/how-to/certificates/setup.md Updates package requirements and introduces method selection documentation.
docs/how-to/certificates/configure.md Documents certificate_enrollment config and updates helper-location example.
docs/how-to/certificates/index.md Removes CEP/CES mention from external resources list.
docs/how-to/certificates/troubleshoot.md Updates troubleshooting guidance toward native LDAP/RPC flow and state files.
docs/explanation/certificates.md Adds method overview/configuration details for LDAP vs CEPCES enrollment.
debian/rules Removes vendored Python install step and adds (currently empty) test override target.
debian/control Moves Python/Samba deps to Recommends and adjusts runtime deps.
debian/adsys.postinst Creates default /etc/adsys.yaml for new installs setting LDAP enrollment.
debian/adsys.install Stops installing legacy Python assets directory.
debian/adsys.apport Removes python3-samba from apport related packages list.
debian/copyright Removes legacy vendored Samba Python copyright stanza.
debian/changelog Adds a test changelog entry for certificate changes.
conf.example/adsys.yaml Documents the new certificate_enrollment configuration key.
go.mod Adds new Go dependencies for LDAP/Kerberos/MSRPC.
go.sum Records new module checksums for added dependencies.
Comments suppressed due to low confidence (1)

debian/rules:88

  • An empty override_dh_auto_test target disables the default test execution in the Debian build. If the intent is not to skip unit tests, call dh_auto_test here (integration tests are already skipped via ADSYS_SKIP_INTEGRATION_TESTS).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/policies/certificate/ldap.go Outdated
Comment thread internal/policies/certificate/ldap.go Outdated
Comment thread internal/policies/certificate/trust.go Outdated
Comment thread internal/policies/certificate/state.go
Comment thread internal/policies/certificate/certificate.go Outdated
Comment thread docs/index.md Outdated
Comment thread internal/policies/certificate/certificate_test.go
Comment thread docs/explanation/certificates.md Outdated
Comment thread internal/policies/certificate/certificate_test.go
Comment thread internal/policies/certificate/ldap_test.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 65 out of 99 changed files in this pull request and generated 14 comments.

Comment thread internal/policies/manager.go
Comment thread internal/policies/manager.go
Comment thread internal/policies/certificate/certificate.go
Comment thread internal/policies/certificate/certificate.go
Comment thread internal/policies/certificate/trust.go Outdated
Comment thread internal/adsysservice/policy.go
Comment thread internal/ad/ad.go
Comment thread cmd/adsys-certsubmit/main.go Outdated
Comment thread cmd/adsys-certsubmit/main.go Outdated
Comment thread internal/policies/certificate/certificate.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 66 out of 100 changed files in this pull request and generated 4 comments.

Comment thread debian/rules
Comment thread internal/policies/certificate/certificate.go
Comment thread internal/policies/certificate/certificate.go Outdated
Comment thread internal/ad/ad.go Outdated
@denisonbarbosa

Copy link
Copy Markdown
Member Author

Pushed follow-up commits addressing the review threads (now resolved). Summary of what changed:

certificate: harden LDAP/TLS and trust store (updated)

  • LDAP MITM / auth-only SASL: StartTLS verifies the DC certificate against the system store plus adsys-managed CAs before the GSSAPI bind, and the configured global trust dir is now honored.
  • CA validation comment made accurate: a discovered self-signed AD CS root can only be sanity-checked (validity window); its trust rests on the authenticated StartTLS+Kerberos channel it was discovered through (TOFU, as Windows autoenrollment trusts the directory). Non-self-signed CAs must chain to an already-trusted anchor.
  • Issued cert is rejected if expired/not-yet-valid; the RPC certificate response is size-capped; key/cert are written via an O_EXCL temp file + rename.

gate Kerberos bypass behind testing.Testing() — the ADSYS_TESTS_WITHOUT_KERBEROS escape hatch can no longer take effect in production builds.

fix orphaned certs, unreachable skip path, state comment — graceful skip path reachable; orphan cleanup; stale comments corrected.

validate enrollment config, CSR size limit, CEPCES packaging — enrollment method validated/normalized across all layers; CSR size checked before the full read; vendored Python reinstalled; adsys-certsubmit accepts the CA via certmonger env vars.

harden LDAP enrollment robustness and renew certs before expiry (new)

  • LDAP-issued certs are re-enrolled on policy refresh when missing or within 30 days of expiry (this path is not certmonger-tracked); docs updated to match.
  • A CA whose root fails to install/verify is skipped (prior state preserved); the system trust store is rebuilt after both installs and orphan removals; a failed renewal retains the still-valid certificate instead of letting orphan cleanup delete it (covered by a new regression test).

Intentional/by design: the code-level default stays cepces (new installs select ldap via postinst); issued-cert checks do not enforce subject/SAN/EKU/chain (AD CS templates legitimately control these and the issuer may be a subordinate CA); renewal is handled in-daemon rather than via certmonger.

@denisonbarbosa
denisonbarbosa force-pushed the ldap-cert-enrollment branch 4 times, most recently from 772d307 to d3ffabf Compare June 26, 2026 21:54
@denisonbarbosa
denisonbarbosa force-pushed the ldap-cert-enrollment branch 2 times, most recently from a292570 to cf28df7 Compare July 6, 2026 13:07
@denisonbarbosa
denisonbarbosa force-pushed the ldap-cert-enrollment branch 2 times, most recently from 29479ac to 07e7c73 Compare July 17, 2026 12:33
@denisonbarbosa
denisonbarbosa requested a review from Copilot July 20, 2026 10:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 78 out of 114 changed files in this pull request and generated 3 comments.

Files not reviewed (2)
  • adsys.pb.go: Generated file
  • adsys_grpc.pb.go: Generated file

Comment thread internal/policies/certificate/trust.go Outdated
Comment thread cmd/adsysd/integration_tests/adsys_test.go Outdated
Comment thread internal/adsysservice/policy.go Outdated
denisonbarbosa and others added 4 commits July 24, 2026 06:57
The native enrollment path has to talk to Active Directory's LDAP
directory as the machine account, but go-ldap can only drive a
GSSAPIClient and ships no Kerberos implementation of its own. Bridging
the two lets us reuse the machine credentials already present in the
host's credential cache instead of provisioning or storing a separate
secret.

The adapter performs the GSSAPI/SASL bind with the machine credentials,
and LDAP discovery upgrades the connection with StartTLS before the bind
so directory responses used for enrollment are protected in transit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The LDAP enrollment method must learn which enrollment services and
certificate templates are available without shelling out to the
Samba-based Python helper. Querying the directory directly makes
discovery a first-class, testable part of the daemon rather than a side
effect of an external process.

The directory client sits behind an interface so tests can inject
responses without a live AD, and connections reuse the machine's
Kerberos cache so no extra credentials are needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Dropping the Samba/CEPCES stack also removes the bookkeeping it provided:
nothing records what has already been enrolled, and the CAs that AD
advertises are not trusted by the system. Left unaddressed, every policy
refresh would request brand new certificates and the issued certificates
would fail to validate.

Persisting enrollment state to disk lets repeated policy applications
reuse the existing key and certificate pairs instead of re-issuing them,
and installing the root CAs discovered over LDAP into the system trust
store means both the issued certificates and the servers presenting them
verify correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Requesting the certificate is the last step that still required cepces.
Submitting the CSR straight to AD CS over the MS-ICPR RPC interface lets
the entire enrollment run natively, authenticated with the machine's
Kerberos credentials and with no Python runtime on the host.

Submission is kept behind an interface so the network call stays out of
unit tests, and the pending (manual-approval) result is handled
explicitly rather than assuming the CA issues immediately.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
denisonbarbosa and others added 17 commits July 24, 2026 06:57
Discover and deterministically validate complete AD CS chains, bind persisted enrollment state to domain and certificate fingerprints, and verify issued and reused key material against the selected issuer.

Authorize published machine templates with a bounded LDAP lookup, safe DACL access checks, and a complete authenticated machine token before enrollment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Read tokenGroups with a base-object query after uniquely resolving the machine, accept signed AD bit flags, and distinguish trusted-root publication from issuer-only certificate sources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Persist issuer and ordered chain bindings per template, retain chains across targeted renewals, verify management operations against only those bindings, and make trust publication rollback ownership-aware.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ge cases

Separate the state envelope from per-leaf validity so a targeted or --all
renewal can replace an expired, missing or mismatched target leaf, or a
target whose old issuer chain expired, without first requiring every stored
leaf to be currently valid. Untouched templates are still validated exactly
before the CA is touched, a failed target keeps its old entry only when that
entry validates, and target state is never migrated until renewal succeeds.

Make trust and leaf artifact paths collision-resistant with a SHA-256 digest
over the full unsanitized CA/object identity and certificate fingerprint, so
distinct raw identities such as "Corp CA" and "Corp-CA" no longer share paths
and one installation's rollback cannot delete another's committed artifacts.

Route removal and unenroll deletions through removeUnreferencedPaths after the
current state is persisted, honoring other object state files so shared
cert/key/chain paths survive, and propagate cleanup errors.

Return aggregated errors from trust installation rollback and join them at
every call site, preserving path detail so leftovers are never reported as a
clean rollback.

Verify certificates against the identity derived from the requested object and
domain, rejecting a state whose object name or identity does not match, which
closes sanitizeName state-file collisions such as "host$" versus "host-".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use raw-identity discriminators for state, leaf, and management identifiers; safely migrate legacy state and preserve cross-object ownership during renewal and removal.

Serialize trust lifecycles across managers and propagate rollback and cleanup failures through enrollment results.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Model AD CS dispositions and persist exact pollable requests with secure key and CSR material. Verify and publish issued key/certificate pairs through a single crash-durable generation pointer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make generation publication and cleanup directory-durable, reconcile interrupted markers against durable state, contain legacy artifact cleanup, and preserve pending polling cursors after committed removals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Attempt independent pending draft cleanup after durable issued-state commits even when generation marker finalization fails. Preserve uncertain generation artifacts for restart reconciliation and propagate all cleanup failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Switching certificate_enrollment from cepces to ldap dispatched straight
to the native flow: it never ran the CEPCES unapply path, so certmonger
kept tracking the requests and CAs registered under
<state>/private/certs/<CA>.<template>.key and
<state>/certs/<CA>.<template>.crt, remaining an active writer of files
the native backend now manages.

Run the legacy unenroll action before the first native enrollment (and
before native unenrollment) whenever the Samba cache is present, so
getcert remove-ca/stop-tracking retires the requests and the TDB is
deleted only after the cleanup ran. The switch is transactional: a
cleanup failure aborts the native enrollment and keeps the legacy state
for the next refresh. When certmonger is not installed the script skips
cleanup, so the leftover cache is removed directly as it cannot be an
active writer.

The helper previously skipped unenroll unless cepces-submit was also
present, although only getcert is needed to stop tracking; unenroll now
requires just certmonger.

Also restore the CEPCES ApplyPolicy coverage dropped earlier in the
series (golden output is byte-identical to the pre-LDAP suite) and add
backend-switch regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CertTemplates RPC forwarded the caller-provided server unchanged into
the LDAP dialer while holding the global certificate mutex. Any local
user could point the daemon at an accepting-but-stalled endpoint (e.g.
adsysctl certificate templates localhost:4444) and, because only
connection establishment had a timeout, block certificate management and
machine policy refreshes indefinitely.

The requested server is now only a selector over the CAs discovered in
the configured domain: discovery always runs against the domain's SRV
candidates with context and per-candidate deadlines, so the daemon never
dials an arbitrary address or port. The query is stateless and touches
no enrollment state, so it takes neither the manager mutex nor the trust
lifecycle lock; cancellation propagates from the caller.

Authorization stays ActionAlwaysAllowed, matching the other read-only
certificate RPCs (list, status, verify, CA info); the operation now
shares their exact confinement and deadline properties. Mutating
operations still require root with ActionPolicyUpdate.

Remove the now-unused exported GetSupportedTemplates helpers that
derived the discovery target from the caller string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fixed 30-day renewal window means a certificate with a seven-day
lifetime is immediately considered due for renewal after issuance and
re-enrolled on every policy refresh.

Bound the window to a third of the certificate's own lifetime: a 6-day
certificate is now reused until its last two days, while year-long
certificates keep the existing 30-day behavior. deriveHealth uses the
same bounded window so freshly issued short-lived certificates report
healthy instead of due_renewal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
deriveHealth only checked NotAfter, so a certificate whose NotBefore is
in the future (issuance clock skew or a rolled-back CA clock) reported
healthy with exit code 0, while certificate verify rejected the same
certificate as outside its validity window.

Add a distinct CERT_HEALTH_NOT_YET_VALID state (not_yet_valid, exit
code 6) derived when the certificate is before its NotBefore, matching
the verify path. The status command help, its generated reference, and
the management documentation now also state that exit code 5 covers both
key mismatch and unparseable results, and the renewal window description
reflects the bounded window.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every certificate reported the state-wide UpdatedAt as its enrollment
time. Since state is also saved after a failed renewal and when an
unrelated certificate is removed, the displayed last_enrolled moved for
certificates that were never re-issued, which is misleading for audit
and monitoring.

Persist a per-template enrolled_at at successful issuance (both direct
and pending-poll completion paths) and report it as last_enrolled,
falling back to the state UpdatedAt for state written before the field
existed. A failed renewal now keeps the original timestamp, removing one
certificate no longer retimes the rest, and only a successful renewal
advances it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The online revocation check called x509.ParseRevocationList and set
RevocationChecked on any parseable response. A forged empty CRL — not
signed by the issuing CA, or long stale — therefore produced
"revoked: no", PASS, and exit 0.

The fetched CRL is now only trusted when its issuer belongs to the
certificate's persisted chain, the signature verifies, and it is inside
its ThisUpdate/NextUpdate window (with a small clock-skew tolerance).
Foreign-signed, expired, not-yet-valid, unparseable, and unreachable
CRLs leave RevocationChecked false with an explanatory message, so the
CLI hides the revoked verdict instead of reporting a clean "no". A
non-OK HTTP status is also no longer fed to the parser.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bring the certificate management documentation in line with the
implementation and make git diff --check clean again:

- vpn.md names the enrolled certificates galacticcafe-CA.*, matching the
  CA in the example, instead of the nonexistent galactic-CA.*.
- manage.md uses 2026/2027 dates so a healthy certificate does not show
  2024 expiry with 210 days remaining.
- The landing page links the management guide from its Certificates
  section, and the setup page states the network prerequisites for the
  LDAP method: StartTLS to the DCs on TCP 389 and the CA's RPC endpoint
  on TCP 135 plus dynamic RPC ports.
- Regenerate the Jammy packaging patch with minimal context so its
  context lines carry no trailing whitespace or space-before-tab
  indentation; the result applied by build-deb.sh is byte-identical and
  the noble symlink follows it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The certificate assertion still ran getcert and expected certmonger's
MONITORING state, so on a fresh install — which now defaults to the
native LDAP method — it either failed outright or, on images where an
older adsys was upgraded, silently kept covering only the legacy
backend.

Pin the enrollment method in /etc/adsys.yaml for each phase and exercise
the flow end to end against the live AD CS:

- native default: first enrollment, health, certificate/key
  correspondence, chain verification, and system trust via adsysctl
  certificate list/cas/verify, then a forced renewal
- explicit CEPCES compatibility: install certmonger and python3-cepces,
  select the legacy backend, and keep the getcert MONITORING assertion
- migration: switching back to LDAP retires the certmonger requests, so
  getcert no longer reports the template as monitored

Assertions on asynchronous results poll instead of relying on a fixed
sleep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mark the migration helper process as test-controlled for gosec, drop the
always-nil manager return from the timestamp fixture, and document why
the shared mock helpers keep their now-constant parameters generic, so
golangci-lint run ./... reports zero issues again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The documentation spell check fails on the certificate management guide:
Vale's Canonical dictionary does not know "untrusted", which the CRL
section uses to describe a revocation list that cannot be tied back to
the certificate's issuer chain.

The word is correct and the surrounding paragraph reads worse without
it, so allow it in the custom word list next to the other negated forms
already listed there ("unescaped", "unparseable").

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@denisonbarbosa

Copy link
Copy Markdown
Member Author

Superseded by a split stack. Same final tree, rebuilt from scratch into reviewable pieces:

PR Scope ~lines
#1448 privilege: normalize sudoers file ending (unrelated drive-by, independent) ~35
#1449 certificate: native LDAP/MS-ICPR enrollment engine + config plumbing ~20,400
#1450 certificate: expose enrolled certificates over gRPC and adsysctl ~2,850
#1451 debian/e2e: fresh-install default flip + per-backend e2e validation ~1,100

Why: this repo merges with merge commits only, so all 48 commits here would land on main permanently. Most of them were review-round hardening (fix(certificate): ... commits alone accounted for ~68% of the diff), which reads as archaeology rather than history. The stack is 7 commits, each of which compiles.

The split is verified: #1448 + #1449 + #1450 + #1451 reproduces this PR's tree byte-for-byte (4219e79f). Full go test ./..., golangci-lint run ./... (0 issues), gofmt and docs spellcheck are green at the stack tip and at each PR boundary.

Closing in favour of the stack. Branch ldap-cert-enrollment is left untouched for reference.

denisonbarbosa added a commit that referenced this pull request Aug 11, 2026
Split out of #1401 as an unrelated drive-by fix, so it can be reviewed
and land on its own.

The generated sudoers file ended with a trailing blank line, and every
policy entry appended its own `\n` whether or not it produced content.
Entries yielding an empty sudoers section emitted a stray newline, and
the file always finished with one blank line more than needed.

Sections are now collected and joined with a single blank-line
separator, so they stay visually separated but the file ends with
exactly one newline. Golden files updated accordingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants