diff --git a/changes/41472-android-cert-san-server b/changes/41472-android-cert-san-server new file mode 100644 index 00000000000..a1e6c9068f8 --- /dev/null +++ b/changes/41472-android-cert-san-server @@ -0,0 +1 @@ +* Added support for the `subject_alternative_name` field on Android certificate templates. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 80cf4641b23..adf1fa5b45e 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1356,11 +1356,17 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string certType := reflect.TypeFor[fleet.CertificateTemplateResponse]() fullCerts := make([]map[string]any, 0, len(certSummaries)) for _, certSummary := range certSummaries { - fullCerts = append(fullCerts, map[string]interface{}{ + cert := map[string]any{ jsonFieldName(certType, "Name"): certSummary.Name, jsonFieldName(certType, "CertificateAuthorityName"): certSummary.CertificateAuthorityName, jsonFieldName(certType, "SubjectName"): certSummary.SubjectName, - }) + } + // Emit subject_alternative_name only when set, so existing GitOps files for templates + // without SAN do not pick up a spurious empty key. + if certSummary.SubjectAlternativeName != "" { + cert[jsonFieldName(certType, "SubjectAlternativeName")] = certSummary.SubjectAlternativeName + } + fullCerts = append(fullCerts, cert) } androidSettings, ok := result[jsonFieldName(mdmT, "AndroidSettings")].(map[string]interface{}) if !ok { diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 8b2c44c44d7..13012583f7e 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -891,6 +891,7 @@ func (MockClient) GetCertificateTemplates(teamID string) ([]*fleet.CertificateTe CertificateAuthorityName: "DIGIDOO", Name: "my_certypoo", SubjectName: "CN=OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", + SubjectAlternativeName: "DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME", }, } } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamControls.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamControls.yaml index 52babdf5854..69a3dec1b08 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamControls.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamControls.yaml @@ -7,4 +7,5 @@ android_settings: certificates: - certificate_authority_name: DIGIDOO name: my_certypoo + subject_alternative_name: DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME subject_name: CN=OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json b/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json index 31e64d919e2..b5fc087357b 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json @@ -145,7 +145,8 @@ { "certificate_authority_name": "DIGIDOO", "name": "my_certypoo", - "subject_name": "CN=OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL" + "subject_name": "CN=OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", + "subject_alternative_name": "DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME" } ] } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml index 6a1e5a99e12..5e93fce8543 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml @@ -22,6 +22,7 @@ controls: certificates: - certificate_authority_name: DIGIDOO name: my_certypoo + subject_alternative_name: DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME subject_name: CN=OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL apple_settings: configuration_profiles: diff --git a/openspec/changes/android-cert-san-attributes/.openspec.yaml b/openspec/changes/android-cert-san-attributes/.openspec.yaml new file mode 100644 index 00000000000..905325fd9b8 --- /dev/null +++ b/openspec/changes/android-cert-san-attributes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-04 diff --git a/openspec/changes/android-cert-san-attributes/design.md b/openspec/changes/android-cert-san-attributes/design.md new file mode 100644 index 00000000000..641c688c5f1 --- /dev/null +++ b/openspec/changes/android-cert-san-attributes/design.md @@ -0,0 +1,349 @@ +## Context + +Fleet currently delivers SCEP-issued client certificates to Android hosts so end users can authenticate to corporate Wi-Fi. The +certificate template (`fleet.CertificateTemplate`, table `certificate_templates`) carries a `subject_name` (e.g. +`"/CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID"`) which is rendered per-host at delivery time and then signed +by a custom SCEP CA. Many real-world EAP-TLS deployments match the user identity in a Subject Alternative Name (UPN, RFC822, DNS, +URI) instead of (or in addition to) the subject DN, so without SAN support the cert is rejected by the network. + +PR #43318 already merged the user-facing contract: `subject_alternative_name` (singular string, comma-separated `KEY=value` pairs) +on the REST API and YAML for the certificate template. None of the corresponding backend, frontend, GitOps, or device-delivery +plumbing has shipped yet. The acceptance criterion is that Fleet can deliver the SANs in the test certs in the linked Google Drive +folder for issue #41472. + +## Goals / Non-Goals + +**Goals:** + +- Round-trip the new optional `subject_alternative_name` field end-to-end: REST create -> DB -> device-facing API -> Android + cert delivery, plus GitOps apply, GitOps generate-gitops, and the Add/Edit Certificate UI. +- Apply the same `$FLEET_VAR_HOST_*` expansion to `subject_alternative_name` that already runs on `subject_name`, with the same + error semantics when a host lacks the data needed for an expansion. +- Keep the feature gated to Fleet Premium on both backend and frontend, matching the existing certificate template feature. +- Migrate cleanly: existing certificate templates have no SAN today, so the column is nullable and the behavior with NULL must + match today's behavior exactly (no SAN included in the CSR). + +**Non-Goals:** + +- iOS/macOS SAN support — Apple SCEP profiles already accept SANs through the Apple-side configuration profile payload. +- SAN types beyond `DNS`, `EMAIL`, `UPN`, `IP`, `URI`. Exotic types (`directoryName`, `registeredID`, `x400Address`, + `ediPartyName`) are not used in modern enterprise authentication and stay out of scope. +- Server-side validation of the SAN string syntax. The Figma dev note explicitly says the server "won't validate SAN, will let + it through, and if it fails, surface error on the host details > OS settings modal." Variable allow-list checks remain + (shared with `subject_name`), but no KEY=value parsing happens server-side. +- Exposing the X.509 `critical` bit on the SAN extension to admins. The agent always emits SAN as non-critical (see "Android + agent: parse SAN string and add SAN extension to PKCS#10 CSR" below). +- Activity log entries for SAN-specific events (the story explicitly says "no activity changes"). +- Changes to `fleetd` (the cross-platform osquery agent). Android certificate delivery uses the **Android Fleet agent** in this + repository's `android/` directory, which is a different binary from `fleetd`. The Android agent IS in scope for this change + and is co-located in the same repo, but it ships from its own release train. +- A second (plural) field. The merged contract is the singular `subject_alternative_name`; we do not split it into a JSON array. + +## Decisions + +### Field name and shape: stay with the merged contract + +PR #43318 documented `subject_alternative_name` (singular string, comma-separated `KEY=value` tokens). The story title and one +checkbox say "subject alternative names" (plural), but the merged docs are the source of truth. + +- **Decision:** The persisted column, Go struct field, JSON field, YAML field, and frontend form field are all named + `subject_alternative_name` / `SubjectAlternativeName`, matching `subject_name` exactly in shape (single string). +- **Alternative considered:** A `[]SAN{Type, Value}` array. Rejected because (a) it diverges from the already-merged docs, (b) the + same comma-separated format is already accepted in `subject_name`, and (c) a single string survives variable expansion with the + exact same code path. + +### Variable expansion: extend the existing helper, not duplicate it + +`Service.replaceCertificateVariables` (`server/service/certificate_templates.go:36`) already expands the supported +`$FLEET_VAR_HOST_*` set inside `subject_name`. The function is parameterized by the input string, so we apply it to +`subject_alternative_name` with no signature change. Validation (`validateCertificateTemplateFleetVariables`) is similarly applied +to both inputs at create time, so unsupported variables are rejected up front rather than at delivery. + +This is not greenfield for Android: the integration test +`server/service/integration_android_certificate_templates_test.go` (`TestCertificateTemplateNoTeamWithIDPVariable`) already +exercises `subject_name = "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"` end-to-end on Android, including the failure path when +the host has no associated IdP user. The IdP-to-Android-host association is wired during the Android enrollment-token flow at +`server/mdm/android/service/pubsub.go:597` (`AssociateHostMDMIdPAccount`). Extending expansion to SAN reuses this proven path. + +- **Decision:** Reuse the helper. The set of supported variables is identical for SN and SAN. Today that set + (`fleetVarsSupportedInCertificateTemplates`) is exactly three names: `HOST_UUID`, `HOST_HARDWARE_SERIAL`, + `HOST_END_USER_IDP_USERNAME`. Other globally-defined variables — `HOST_PLATFORM`, `HOST_END_USER_IDP_USERNAME_LOCAL_PART`, + `HOST_END_USER_IDP_GROUPS`, `HOST_END_USER_IDP_DEPARTMENT`, `HOST_END_USER_IDP_FULL_NAME`, and the legacy + `HOST_END_USER_EMAIL_IDP` — are NOT accepted in either field. +- **Alternative considered:** Tracking SN-only vs SAN-only allowed variables. Rejected — there is no current product reason to + diverge, and unifying keeps the contract simpler. If a customer needs a SAN-specific variable later (see Open Questions), + add it to the shared list and both fields gain it together. + +### Storage shape: nullable column, mirror `subject_name`'s type + +- **Decision:** New migration adds `subject_alternative_name TEXT NULL`, matching the existing `subject_name TEXT` column on + `certificate_templates` (verified in `server/datastore/mysql/migrations/tables/20251124140138_CreateTableCertifcatesTemplates.go` + and `server/datastore/mysql/schema.sql`). NULL means "no SAN", which is the existing default behavior. The spec's 4096-byte + length cap is enforced at the service layer (see "Lightweight server-side validation"), not by the column type — `TEXT` + comfortably accommodates 4096 bytes plus headroom. +- The Go struct field is a plain `string` with `json:"subject_alternative_name,omitempty"`. The JSON response deterministically + omits the key when empty/NULL (per `omitempty`). On the request side, both an omitted key and an empty string deserialize + to `""` and store as NULL. +- **Whitespace policy:** `strings.TrimSpace(value) == ""` -> store NULL. Non-empty values are stored verbatim — no per-token + trimming, no leading/trailing-whitespace mutation. This preserves admin intent and keeps GitOps idempotent (no churn). The + Android agent applies its own whitespace tolerance at parse time (see the agent decision below); the two layers are + independent. +- **Alternative considered:** A separate join table (one row per SAN attribute). Rejected — the format is already a single + human-authored string, not structured data; the parsing happens once at delivery time. + +### Variable expansion failure semantics on SAN match SN + +If `subject_name` references `$FLEET_VAR_HOST_END_USER_IDP_USERNAME` and the host has no IdP username, delivery fails today. Same +error must fire for SAN. + +- **Decision:** `replaceCertificateVariables` is called for SAN with the same error-wrapping. The cert template moves to + `failed` state and a delivery-failure path identical to the existing one. +- **Alternative considered:** Best-effort expansion (drop unresolved tokens). Rejected — this would silently issue certs that + could let a misconfigured host onto Wi-Fi as the wrong identity. + +### Device-facing response: extend the existing endpoint, do not add a new one + +The Android Fleet agent (Kotlin source under `android/app/src/main/java/com/fleetdm/agent/`) fetches +`CertificateTemplateResponseForHost` from `/api/fleetd/certificates/{id}` to get the per-host rendered subject name, the SCEP +challenge, etc. We add `SubjectAlternativeName` to that struct (same JSON tag), so the existing endpoint returns the rendered SAN +alongside the rendered SN. No new endpoint is needed. + +- **Decision:** Add `SubjectAlternativeName string` (with `json:"subject_alternative_name,omitempty"`) to + `CertificateTemplateResponse` and its embedded summary / per-host structs. The agent's `GetCertificateTemplateResponse` data + class in `ApiClient.kt` gains a matching `@SerialName("subject_alternative_name") val subjectAlternativeName: String? = null`. +- Backwards-compat: the agent has historically tolerated unknown fields (kotlinx.serialization with `ignoreUnknownKeys = true`), + so a new server can ship before a new agent is rolled out without breaking older agents. +- The Android agent — not the server — owns adding the SAN extension to the CSR. That logic lives in this repo (see Decision + 9), so we control both halves. + +### Android agent: parse SAN string and add SAN extension to PKCS#10 CSR + +The agent must convert the rendered SAN string ("DNS=example.com, UPN=marko@corp.example.com") into BouncyCastle +`GeneralNames` and attach it to the CSR as an `extensionRequest` attribute (PKCS#9 OID `1.2.840.113549.1.9.14`). Today +`scep/ScepClientImpl.kt:168-179` (`buildCsr`) uses `JcaPKCS10CertificationRequestBuilder` and adds only the challenge-password +attribute. We extend it to also add the extensionRequest when SAN is non-empty. + +- **Decision:** Introduce `scep/SubjectAlternativeNameParser.kt` (or equivalent) with a single public function + `parse(sanString: String): GeneralNames?`. Behavior: + - Returns `null` for empty / whitespace-only input — caller skips adding the extension. + - Splits on `,` (no quoting support; matches the simple parsing the docs and `subject_name` already use). + - Trims whitespace around each token. + - Splits each token on the first `=`. Left side is the KEY (case-insensitive, normalized to upper); right side is the value + (kept verbatim, so it can include `:` for URIs and `@` for emails). + - Maps KEY to `GeneralName` per the table below. The KEY names match what the Figma exposes to admins + (`UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME` is the modal placeholder), and + cover the full set of SAN types used for EAP-TLS / Wi-Fi authentication. Anything else throws `IllegalArgumentException` + with a message naming the offending KEY — caller wraps it as a `ScepCsrException` so the cert state goes to `failed` and + the failure surfaces in the host's "OS settings" modal per the Figma dev note ("if it fails, surface error on the host + details > OS settings modal"), rather than silently issuing a cert without the requested SAN. + + Sourcing rationale for the v1 KEY set: per the Figma dev note ("Support all available fields for SAN"), v1 covers the SAN + types that real-world enterprise PKI actually deploys. RFC 5280 §4.2.1.6 defines nine `GeneralName` choices, but four are + effectively dead in modern enterprise authentication (`directoryName`, `registeredID`, `x400Address`, `ediPartyName`). The + remaining five are the ones admins actually use: + + | KEY | `GeneralName` tag | BC tag # | Where it shows up in real deployments | + |---|---|---|---| + | `DNS` | `dNSName` | 2 | Server certs, mTLS, EAP-TLS by hostname. Effectively universal. Already in the merged Fleet REST API docs example. | + | `EMAIL` | `rfc822Name` | 1 | User certs, S/MIME, EAP-TLS where the username is an email. In the Figma placeholder. | + | `UPN` | `otherName` (OID `1.3.6.1.4.1.311.20.2.3`) | 0 | Active Directory-integrated EAP-TLS, NPS, Intune, smart card login. The de facto identity for Microsoft-shop Wi-Fi. In the Figma placeholder and merged docs. | + | `IP` | `iPAddress` | 7 | Internal services, IoT, services accessed by IP literal. Common in enterprise PKI for internal hosts. | + | `URI` | `uniformResourceIdentifier` | 6 | SPIFFE/SPIRE IDs, S/MIME identity URIs, modern cloud-native service identity. | + + Encoding details for the non-trivial cases: + + - `DNS` -> `GeneralName(GeneralName.dNSName, DERIA5String(value))`. + - `EMAIL` -> `GeneralName(GeneralName.rfc822Name, DERIA5String(value))`. User-facing key is `EMAIL=`; `RFC822=` is not a + synonym in v1. + - `URI` -> `GeneralName(GeneralName.uniformResourceIdentifier, DERIA5String(value))`. + - `IP` -> parse value with `InetAddress.getByName(value)`, then + `GeneralName(GeneralName.iPAddress, DEROctetString(addr.address))`. 4 bytes for IPv4, 16 for IPv6. Reject anything that + fails to parse as IPv4 or IPv6. + - `UPN` -> `GeneralName(GeneralName.otherName, DERSequence(arrayOf(ASN1ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), + DERTaggedObject(true, 0, DERUTF8String(value)))))` per Microsoft KB258605 / RFC 4556 §3.2.1. The `[0] EXPLICIT` tag on + the value (the `true` arg in `DERTaggedObject`) is required — without it the `otherName` is uninterpretable to Windows / + NPS / Intune supplicants. This is the single most error-prone encoding. + +- **Decision:** In `buildCsr`, when `parse(config.subjectAlternativeName ?: "")` returns non-null, append the extensionRequest + attribute: + ```kotlin + val extensions = ExtensionsGenerator().apply { + addExtension(Extension.subjectAlternativeName, false, generalNames) + }.generate() + csrBuilder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensions) + ``` + The SAN extension is **non-critical** (`false`), and this is hard-coded — it is not exposed to admins in any UI or YAML + field. Rationale per RFC 5280 §4.2.1.6: "If the subject field contains an empty sequence, then the issuing CA MUST include a + subjectAltName extension that is marked as critical. ... If the subject field is non-empty, conforming CAs SHOULD mark the + subjectAltName extension as non-critical." Fleet always requires `subject_name` to be non-empty, so the SAN is always SHOULD + non-critical. Practical implications: + - Modern Wi-Fi supplicants (wpa_supplicant, Intune NAC, all major Android/Windows/macOS supplicants from the last decade) + honor SAN regardless of the critical bit. EAP-TLS identity matching works the same way. + - Marking critical when subject DN is also present would risk CSR rejection from enterprise CAs that follow RFC 5280 + strictly, and would risk supplicants that don't fully process SAN rejecting otherwise-valid certs. + - IT admins do not configure this — there is no checkbox. If a customer ever needs critical SAN (very rare, requires empty + subject), that's a separate feature. +- **Alternatives considered:** + - Doing the parse server-side and shipping a structured JSON to the agent. Rejected — the agent already has BouncyCastle and + is the authoritative place that builds the CSR; pushing structured types over the wire would create two formats (string + in the API, struct on the wire to the agent) that must stay in sync forever. + - Parsing on the server only as a *validation* step (to surface bad input early). Rejected for the MVP — server-side + validation in this change is limited to variable-allow-list checks, matching how `subject_name` works. We can add stricter + validation later if customers footgun themselves with bad SAN syntax. + - Marking the SAN extension *critical*. Rejected — most enterprise issuing CAs reject critical SAN when the subject DN is + also non-empty; non-critical is the safe default. +- **Edge cases:** + - Empty value (`"DNS="`): treat as malformed, throw with a clear message. + - Repeated KEY (`"DNS=a, DNS=b"`, `"EMAIL=u@x, EMAIL=u@y"`, etc.): each occurrence produces one `GeneralName` entry of the + corresponding type, in document order. RFC 5280 §4.2.1.6 permits any number of entries of the same type, and real + deployments use this — e.g. Wi-Fi configs with multiple DNS SANs, user certs with both work and personal email SANs. + - Trailing comma: skip empty tokens. + - Unparseable IP (`"IP=not.an.address"`): throw — IP value must parse as IPv4 (dotted-quad) or IPv6 (colon-hex) for the raw + byte encoding to be correct. + - Server-side variable expansion failed and left a literal `$FLEET_VAR_*` in the value: the agent should NOT special-case + this; it just gets passed through and the CA rejects the cert. The server should never send unexpanded variables, but the + agent does not need to defend against this. + +### GitOps: extend the spec struct in lockstep with the persisted struct + +`fleet.CertificateTemplateSpec` (the YAML-facing type, `server/fleet/app.go`) gets `SubjectAlternativeName`. The YAML key under +`controls.android_settings.certificates[]` matches the docs verbatim. `pkg/spec/gitops.go` already validates the existing fields; +SAN gets the same validation (length, no unsupported variables). `cmd/fleetctl/generate_gitops.go` exports the field whenever +non-empty, omits it when empty, so existing GitOps files round-trip without churn. + +### Frontend: add an optional input next to subject name, and switch the modal to the always-enabled-Add pattern + +Match the Figma (node 2:130). Two modal changes ship together: + +1. **Add the SAN input.** Place a `subject_alternative_name` text input directly under the existing `subject_name` input in + `AddCertificateModal.tsx`. Help text: "Separate SAN fields by ', '." Placeholder mirrors the Figma: + `UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME`. Apply the same Premium gate that + already hides the certificate feature for non-Premium tenants (the modal is already only mounted for Premium users; no new + gate is needed at this layer beyond not adding a non-Premium code path). The client-side validation rule on the SAN field + is permissive (any non-empty string is accepted at form level), with server-side validation as the source of truth — this + matches how `subject_name` is handled today. + +2. **Switch the Add button to always-enabled with on-submit field highlighting.** Per the Figma's second dev note ("Add button + is always enabled. If user hits Add without all required fields, then light up required fields"), replace the existing + `disabled={!formValidation.isValid || isUpdating}` (`AddCertificateModal.tsx:187`) with `disabled={isUpdating}` so the + button is enabled whenever the form is not in flight. Add an `attemptedSubmit` state that flips to `true` on the first + click of Add. While `attemptedSubmit` is `true`, each required field's `` / `` renders its + validation error (e.g. "Name field must be completed") inline beneath the field, matching the example screenshot in the + Figma (the "Add user" modal showing red "field must be completed" lines under empty inputs). The submit handler short- + circuits and does not call the API while validation fails — only the visual state changes. Once the user fixes the + required fields and the form becomes valid, submission proceeds normally. + + This is a **modal-wide UX change**, not SAN-specific. It changes how the Name, Certificate authority, and Subject name + fields render their validation errors too. The existing tooltip on the disabled button (`disableTooltip={...}`) is + removed — it no longer applies. Required-ness is unchanged: Name, Certificate authority, and Subject name remain required; + `subject_alternative_name` remains optional and is never highlighted by the missing-required-field flow. + + The pattern is consistent with how `UserForm.tsx` already shows errors after submit/blur in the user-management area, so + no new shared infrastructure is needed; we just rewire the cert modal's local state machine. + +### Lightweight server-side validation: format and KEY allow-list, no value content checks + +The Figma dev note ("we won't validate SAN, we will let it through, and if it fails, surface error on the host details > OS +settings modal") sets a permissive default and pushes failure handling to the agent and the host-details modal. We follow +that intent for *value content* (don't reimplement a CSR parser server-side; values may contain unexpanded `$FLEET_VAR_*` at +create time), but we add minimal validation at create time for the highest-leverage admin-error classes: + +1. **Token shape:** every non-empty comma-separated token contains exactly one `=`. Rejects `"DNS=ok, OOPS"`. +2. **KEY allow-list:** KEY (case-insensitive, normalized to upper) is one of `DNS`, `EMAIL`, `UPN`, `IP`, `URI`. Rejects + `"FOO=bar"`, `"RFC822=user@x"`, `"EMIAL=..."` (typo). +3. **Variable allow-list:** `$FLEET_VAR_*` references inside SAN values must be in + `fleetVarsSupportedInCertificateTemplates`. This already runs for `subject_name`; we extend the same call to SAN. +4. **Length cap:** total SAN string under 4096 bytes. Cheap DOS protection. + +Failure is a 422 invalid-argument error from the create endpoint, with the field name `subject_alternative_name` and a +message naming the offending token / KEY / variable. + +What we explicitly do *not* validate server-side: + +- Per-key value contents. `IP=$FLEET_VAR_HOST_FOO` (hypothetical future variable) cannot be IP-parsed at create time. Same + for URI, hostname, and email regex checks. Those happen on the agent at delivery time, where the value is already expanded. +- Anything that would require a real CSR / X.509 parser. The agent is the authoritative place that builds the CSR. + +**Why deviate from the strict reading of the Figma note:** the "we won't validate SAN" note's primary concern is the cost of +reimplementing a CSR parser server-side and keeping it in sync with the agent. KEY allow-listing and shape checks don't fall +into that category — they are 10 lines of code, can never drift (the allow-list is a list of strings), and prevent the most +common admin frustration: typo `RFC822=`, push to N hosts, debug for hours. This is captured as an Open Question for designer +review before merge. + +**Industry precedent:** smallstep/step-ca and AWS Private CA both validate SAN format (key allow-list, length, basic shape) +at template / configuration storage time, while deferring value-content validation to the issuance step. OpenSSL `req` +validates strictly at CSR generation. Microsoft AD CS validates at the CA on CSR receipt. Validating at the boundary closest +to where the data is consumed is the universal pattern; we already do that on the agent. Adding the format-only checks at +ingress is a small UX improvement that does not violate the pattern. + +### Premium tier gate is enforced server-side, frontend just hides the feature + +- **Decision:** The certificate template service methods (`CreateCertificateTemplate`, etc.) gain an explicit + `svc.License.IsPremium()` check if one is not already present, before SAN-bearing payloads are accepted. Frontend tier-gating + remains UX-only. +- **Alternative considered:** Gating only the SAN field, not the entire feature. Rejected — Android cert templates as a whole + are a Premium feature; SAN is just one more field. + +## Risks / Trade-offs + +- **[Variable-expansion error noise]** Hosts that lack `end_user_idp_username` will start failing more cert deliveries if admins + reference that variable in SAN. -> **Mitigation:** Same delivery-failure path the SN already uses; surface in the existing + certificate-status UI; document in the feature guide that SAN/SN variables both require the host to have the underlying data. +- **[Schema migration on a table that may have rows in production]** The column is additive and nullable, so the migration is + safe to run online. -> **Mitigation:** standard Fleet migration tooling (`make migration`); no backfill needed. +- **[Mixed-version fleet — must ship agent first]** A fleet running an older Android Fleet agent against a newer Fleet server + will fetch the SAN field in the API response, ignore it (kotlinx.serialization tolerates unknown fields), and submit a CSR + without the SAN extension — silently issuing a cert that will fail the EAP-TLS match the admin expected. The "agent ignores + unknown fields" property prevents a *crash* but not a *feature regression*. -> **Mitigation (mandatory):** ship the new + Android agent **before** the Fleet server surfaces SAN through any user-facing path (UI, GitOps, REST). The agent build is + forward-compatible — it reads `subject_alternative_name` if present, simply omits the SAN extension if absent — so it can + ship against any current server with no behavior change. Optional belt-and-suspenders: server-side User-Agent check that + refuses to return a non-empty SAN to agents below the SAN-supporting version, falling back to the cert state going to + `failed` for that host (forces admin attention rather than silent miscertification). Ship-order is captured in Migration + Plan. +- **[BouncyCastle ASN.1 encoding bugs]** UPN-as-OtherName is the most error-prone case: the value must be wrapped as + `DERUTF8String` inside the `OtherName` SEQUENCE, with the right OID. A wrong wrapping produces a CSR the CA will sign but + whose UPN is uninterpretable to network supplicants. -> **Mitigation:** Unit test `SubjectAlternativeNameParserTest` decodes + the produced extension back through `GeneralNames.getInstance(...)` and asserts on the round-tripped values; integration + test against the test certs in the issue's Drive folder before shipping. +- **[Behavior on empty string]** Whitespace-only `subject_alternative_name` could pass to the CSR layer and produce an invalid + cert. -> **Mitigation:** Trim and treat empty-after-trim as "no SAN" at the service layer. +- **[GitOps round-trip drift]** If `generate-gitops` exports differently than apply parses, customers see spurious diffs. -> + **Mitigation:** Exact-string round-trip test in `cmd/fleetctl` covering a template with and without SAN. + +## Migration Plan + +The agent ships **before** any server-side user-facing exposure of the SAN field. The agent build is forward-compatible against +older Fleet servers, so it can land at any time without coordinating with a specific Fleet server release. + +1. **Android Fleet agent release (ships first).** Add the SAN field to `GetCertificateTemplateResponse` in `ApiClient.kt`, the + new `SubjectAlternativeNameParser`, and the `buildCsr` extension wiring. Ship through the agent's existing release train + (typically Google Play). The new agent: reads `subject_alternative_name` from the per-host template response if present; + if absent or empty, builds the CSR exactly as today (no SAN extension). Wait for this build to roll out broadly to the + field before proceeding. +2. **Server backend release.** Land the migration (additive nullable column), Go type changes, datastore CRUD, + variable-expansion, Premium gate, and the device-facing endpoint that now returns `subject_alternative_name`. The REST + create endpoint accepts the new field, but it is **not yet documented or surfaced in the UI**. GitOps validate accepts the + field but generate-gitops does not yet emit it. (Documenting this step is a no-op for admins if they don't poke the REST + API directly — agents in the field already support SAN, so even an admin who finds the field manually gets correct + behavior on any host whose agent has updated.) +3. **GitOps generate-gitops + Frontend modal + REST docs visibility.** Round-trip tests pass; Add/Edit Certificate modal + exposes the SAN input; the merged docs from PR #43318 (already in `docs-v4.86.0`) ship to fleetdm.com along with the + release. This is the moment customers can discover and use the feature. +4. **Feature guide update at fleetdm.com** (https://fleetdm.com/guides/connect-end-user-to-wifi-with-certificate#android-deploy-certificate). + +Rollback: server side, revert the migration (column drop is safe because nothing in the old code reads or writes the column) +plus the code changes. Android agent side, agent ignores any unrecognized JSON field on the server, and treats a missing field +as "no SAN" — so rolling back the server while the new agent is still in the field has no adverse effect. + +## Open Questions + +- The cert-template variable allow-list today is `HOST_UUID`, `HOST_HARDWARE_SERIAL`, `HOST_END_USER_IDP_USERNAME` only. The + Figma placeholder shows `UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME` — i.e. it + assumes the requesting customer's IdP username works for both UPN and email slots (typical for tenants whose IdP usernames + are emails). No new `HOST_END_USER_IDP_EMAIL` variable is needed for v1. Re-evaluate if the requesting customer's IdP + usernames are not in email form. +- The lightweight server-side validation proposed under "Lightweight server-side validation" deviates from the Figma dev + note's "we won't validate SAN, we will let it through" guidance. We believe the note's intent is to not reimplement a CSR + parser server-side, not to let typos through unchecked, but this should be confirmed with the designer before + implementation. diff --git a/openspec/changes/android-cert-san-attributes/proposal.md b/openspec/changes/android-cert-san-attributes/proposal.md new file mode 100644 index 00000000000..bb569c9a65c --- /dev/null +++ b/openspec/changes/android-cert-san-attributes/proposal.md @@ -0,0 +1,99 @@ +## Why + +IT admins managing Android hosts need to deliver certificates whose Subject Alternative Name (SAN) carries identifiers like RFC822/email, +UPN, DNS, or URI so end users can authenticate to corporate Wi-Fi (e.g. EAP-TLS that matches on UPN). Today Fleet only accepts a +`subject_name` for the Android certificate template, so any cert profile that requires a SAN cannot be issued from Fleet, blocking the +"connect end user to Wi-Fi with certificate" flow on Android. Tracked in issue #41472 for milestone 4.86.0. + +## What Changes + +PR #43318 already merged the REST API and YAML *documentation* for a new optional `subject_alternative_name` field on certificate +templates. This change implements the rest of the feature behind that contract: + +- Persist `subject_alternative_name` on certificate templates (new nullable column + struct/spec field). +- Accept and validate `subject_alternative_name` on the create/update certificate template REST endpoints, including + `$FLEET_VAR_HOST_*` variable expansion at delivery time. +- Return the rendered `subject_alternative_name` to the Android Fleet agent on the device-facing certificate-template endpoint + (`/api/fleetd/certificates/{id}`), alongside the existing rendered `subject_name`. +- Update the **Android Fleet agent** (Kotlin source under `android/app/src/main/java/com/fleetdm/agent/` — separate from + `fleetd`) to parse the rendered SAN string and include a non-critical SAN extension in the PKCS#10 CSR it submits to the + SCEP CA. New parser converts the user-facing `"KEY=value, KEY=value"` format from the Figma into BouncyCastle + `GeneralNames`. v1 covers the five SAN attribute types that real-world enterprise PKI actually deploys for the use cases in + scope (Wi-Fi/EAP-TLS, internal mTLS, S/MIME, modern service identity): `DNS`, `EMAIL`, `UPN`, `IP`, `URI`. These map + internally to X.509 `dNSName`, `rfc822Name`, `otherName` (UPN with OID `1.3.6.1.4.1.311.20.2.3` per Microsoft KB258605), + `iPAddress`, and `uniformResourceIdentifier` respectively. Exotic types (`directoryName`, `registeredID`, `x400Address`, + `ediPartyName`) remain out of scope — they are not used in modern enterprise authentication. +- Parse and apply `subject_alternative_name` from GitOps YAML (`controls.android_settings.certificates[]`). +- Emit `subject_alternative_name` from `fleetctl generate-gitops` for each certificate template. +- Add a SAN text input to the Add/Edit Certificate UI (Manage > Controls > OS Settings > Certificates), with the same validation + shape as the documented format ("DNS=example.com, UPN=..."). +- Change the Add/Edit Certificate modal's submit-button behavior to match the Figma's second dev note: the "Add" button is + always enabled, and clicking it with required fields empty surfaces inline "field must be completed" errors against the + empty fields (mirroring the "Add user" modal pattern shown in the Figma example screenshot). This replaces the current + "button disabled until form valid" behavior. Note: this is a modal-wide UX change, not SAN-specific — it affects how the + existing Name, Certificate authority, and Subject name fields surface validation errors too. +- Premium-only: feature stays gated to Fleet Premium on both backend and frontend (matches existing certificate-template behavior). +- Update the feature guide at https://fleetdm.com/guides/connect-end-user-to-wifi-with-certificate#android-deploy-certificate. + +Non-goals: + +- iOS/macOS SAN support (Apple SCEP profile already supports SANs through the configuration profile payload — out of scope here). +- SAN attribute types beyond DNS, EMAIL, UPN, IP, URI. Exotic types (directoryName, registeredID, x400Address, ediPartyName) + are out of scope — they are not used in modern enterprise authentication. +- Server-side validation of SAN *value content* (e.g. is the IP literal a valid IP address, does the URI parse, does the + email have an `@`). Values can contain unexpanded `$FLEET_VAR_*` at create time, so server-side content checks would + false-positive; value parsing belongs in the agent at delivery time. The server still performs **format-only** validation + (token shape, KEY allow-list, variable allow-list, length cap) at create time — see "Lightweight server-side validation" + in design.md, conditional on designer confirmation. +- Exposing the X.509 SAN-extension `critical` flag to admins. The agent always emits the SAN extension as **non-critical** per + RFC 5280 §4.2.1.6 (subject DN is non-empty, so SHOULD non-critical) — admins do not configure this. +- Changes to `fleetd` (the cross-platform osquery agent). Android certificate delivery uses the Android Fleet agent in this + repository's `android/` directory, which is *not* `fleetd` — those are different binaries with different release trains. +- Activity log additions (story explicitly says "no activity changes"). + +## Capabilities + +### New Capabilities + +- `android-cert-san`: Authoring, storage, GitOps round-trip, server-side variable expansion, and Android-agent CSR construction + for the optional Subject Alternative Name on Android certificate templates, including Premium tier gating. + +### Modified Capabilities + +(None — no prior accepted spec covers Android certificate templates in `openspec/specs/`.) + +## Impact + +- Database: one additive migration adding `subject_alternative_name VARCHAR NULL` (or `TEXT NULL`) to `certificate_templates`. +- Backend types: `fleet.CertificateTemplate`, `fleet.CertificateTemplateSpec`, request/response structs in + `server/service/certificates.go`, and the cert template datastore methods in `server/datastore/mysql/certificate_templates.go`. +- Variable expansion: extend `replaceCertificateVariables` (`server/service/certificate_templates.go`) to also expand + `subject_alternative_name`. +- Device-facing endpoint: `CertificateTemplateResponseForHost` (and `GetDeviceCertificateTemplate`) carries the rendered SAN + back to the Android agent. `server/mdm/android/service/service.go` (`BuildAndSendFleetAgentConfig`) and the + `AgentCertificateTemplate` payload in `server/mdm/android/android.go` are unchanged in shape — the agent still fetches the + full template by UUID, the new field just rides on that response. +- **Android Fleet agent (Kotlin, `android/app/src/main/java/com/fleetdm/agent/`)**: + - `ApiClient.kt` — `GetCertificateTemplateResponse` data class gains `subjectAlternativeName: String?`. + - `scep/ScepClientImpl.kt` — `buildCsr()` adds an `extensionRequest` attribute carrying the SAN extension when present. + - New SAN parser (e.g. `scep/SubjectAlternativeNameParser.kt`) converting `"KEY=value, KEY=value"` to BouncyCastle + `GeneralNames`, covering DNS, RFC822, URI, and UPN (UPN encoded as `OtherName` with OID `1.3.6.1.4.1.311.20.2.3` per + Microsoft KB258605 / RFC 4556 §3.2.1). + - Tests under `app/src/test/` (`ScepClientImplTest`, `CertificateEnrollmentHandlerTest`, + `testutil/TestCertificateTemplateFactory`) and a new `SubjectAlternativeNameParserTest`. + - No new third-party deps — BouncyCastle 1.78.1 (`bcprov-jdk18on` + `bcpkix-jdk18on`) is already on the classpath. +- GitOps: `pkg/spec/gitops.go` (parse/validate) and `cmd/fleetctl/generate_gitops.go` (export). +- Frontend: `frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/` (form, validation, + helpers) and the corresponding API client typings. +- Docs: `docs/Configuration/yaml-files.md` and `docs/REST API/rest-api.md` are already updated by PR #43318. The user-facing feature + guide at fleetdm.com/guides/connect-end-user-to-wifi-with-certificate must be updated. Android agent's `CHANGELOG.md` should + note the SAN-extension behavior change. +- Tests: integration tests for the certificate template CRUD endpoints, GitOps round-trip test in `cmd/fleetctl`, frontend Jest + tests for the modal, and Kotlin unit tests for the SAN parser, the CSR builder's SAN extension, and the enrollment handler. +- Release coordination: **Android agent ships first** (via the agent's release train, typically Google Play). Once the new + agent is rolled out broadly, the Fleet server change ships and the SAN UI/YAML/API surface is exposed. Shipping the server + first would silently break Wi-Fi auth for any admin who sets a SAN value while older agents are still in the field — those + agents tolerate the new JSON field (no crash) but strip the SAN out of the CSR, producing certs without the requested SAN + extension. The old certs continue to work as before, but any new cert with a SAN-bearing template would fail to authenticate. + See design.md Migration Plan for the gated rollout. +- Risk: Low. No load testing required. Premium-only — both backend service method and frontend form must check tier. diff --git a/openspec/changes/android-cert-san-attributes/specs/android-cert-san/spec.md b/openspec/changes/android-cert-san-attributes/specs/android-cert-san/spec.md new file mode 100644 index 00000000000..5bfa770de3c --- /dev/null +++ b/openspec/changes/android-cert-san-attributes/specs/android-cert-san/spec.md @@ -0,0 +1,381 @@ +## ADDED Requirements + +### Requirement: Persist optional subject alternative name on certificate templates + +The certificate template entity SHALL include an optional `subject_alternative_name` field of type string. The field SHALL be +persisted to the `certificate_templates` table as a nullable `TEXT` column (matching the existing `subject_name TEXT` column). +A whitespace-only value SHALL be stored as NULL and treated equivalently to "no SAN". A non-empty value SHALL be stored +**verbatim** — no per-token trimming, no leading/trailing-whitespace mutation — to preserve admin intent and keep GitOps +round-trips idempotent. JSON serialization uses Go's `omitempty`, so the response **deterministically omits** the +`subject_alternative_name` key when the stored value is NULL or empty. + +#### Scenario: Create with SAN value, stored verbatim + +- **WHEN** an admin POSTs a certificate template with + `subject_alternative_name = "DNS=example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"` +- **THEN** the template SHALL be stored with that exact string (byte-identical) in the `subject_alternative_name` column +- **AND** the response body SHALL echo the same value under the same key + +#### Scenario: Create with surrounding whitespace, stored verbatim + +- **WHEN** an admin POSTs `subject_alternative_name = " DNS=a.example.com , UPN=marko@x "` (deliberate whitespace) +- **THEN** the persisted value SHALL be the exact original string, byte-identical, with no per-token or outer trimming on + the server +- **AND** GitOps round-trip (apply -> store -> generate-gitops -> apply) SHALL preserve the exact same bytes + +#### Scenario: Create without SAN value (existing behavior preserved) + +- **WHEN** an admin POSTs a certificate template with no `subject_alternative_name` field, or with an empty string +- **THEN** the template SHALL be stored with NULL in `subject_alternative_name` +- **AND** the response body SHALL omit the `subject_alternative_name` key entirely (matching `json:",omitempty"`), with no + validation error + +#### Scenario: Whitespace-only SAN treated as NULL + +- **WHEN** an admin POSTs a certificate template with `subject_alternative_name = " "` (whitespace only, no other content) +- **THEN** the value SHALL be stored as NULL (using `strings.TrimSpace(value) == ""` as the test) +- **AND** the response body SHALL omit the `subject_alternative_name` key entirely + +### Requirement: Lightweight SAN format validation at create time + +The certificate template create endpoint SHALL perform format-only (not value-content) validation on `subject_alternative_name` +at create time. Specifically: every non-empty comma-separated token MUST contain exactly one `=`; the KEY (left of `=`, +case-insensitive) MUST be in `{DNS, EMAIL, UPN, IP, URI}`; the total length of the SAN string MUST be under 4096 bytes. The +server SHALL NOT validate the value-content (right of `=`) — value content can include `$FLEET_VAR_*` references that have not +yet been expanded at create time, and value-content parsing belongs to the agent at delivery time. Failures SHALL return a 422 +invalid-argument error scoped to the `subject_alternative_name` field, with a message naming the specific token, KEY, or +condition that failed. + +This requirement is conditional on designer confirmation (see design.md Open Questions). If the designer rejects format +validation, the server-side validation is limited to the variable allow-list (next requirement) and the agent becomes the +sole gatekeeper. + +#### Scenario: Token missing `=` + +- **WHEN** the create payload contains `subject_alternative_name = "DNS=ok, OOPS"` +- **THEN** the server SHALL return 422 with a message identifying the token `OOPS` as missing `=` +- **AND** no template SHALL be persisted + +#### Scenario: Unknown KEY + +- **WHEN** the create payload contains `subject_alternative_name = "FOO=bar"` or `"RFC822=user@x"` +- **THEN** the server SHALL return 422 with a message identifying the offending KEY and listing the allowed set + +#### Scenario: Length exceeds cap + +- **WHEN** the create payload contains a `subject_alternative_name` of 4097+ bytes +- **THEN** the server SHALL return 422 with a message identifying the length cap + +#### Scenario: Value content with unexpanded variable accepted + +- **WHEN** the create payload contains `subject_alternative_name = "IP=$FLEET_VAR_HOST_UUID"` (unexpanded) +- **THEN** the server SHALL accept the create — value content is not validated, the variable allow-list passes +- **AND** at delivery time the agent SHALL fail to parse the resulting unexpanded literal `$FLEET_VAR_HOST_UUID` as IP, and + the failure SHALL surface in the host's "OS settings" modal (this is expected — admins should not put non-IP-shaped + variables in `IP=` slots) + +### Requirement: Validate variables in SAN at create time + +The system SHALL validate any `$FLEET_VAR_*` references inside `subject_alternative_name` against the same allowed set already +applied to `subject_name` (`fleetVarsSupportedInCertificateTemplates` in `server/service/certificate_templates.go`). At time of +writing, that set is exactly: + +- `HOST_UUID` +- `HOST_HARDWARE_SERIAL` +- `HOST_END_USER_IDP_USERNAME` + +Unsupported variables SHALL be rejected with a 422 invalid-argument error. If the allow-list grows later, both `subject_name` +and `subject_alternative_name` SHALL pick up the new entries via the same shared list (no SAN-specific divergence). + +#### Scenario: Supported variable accepted + +- **WHEN** the SAN string references `$FLEET_VAR_HOST_UUID`, `$FLEET_VAR_HOST_HARDWARE_SERIAL`, or + `$FLEET_VAR_HOST_END_USER_IDP_USERNAME` (each tested individually) +- **THEN** the create call SHALL succeed + +#### Scenario: Unsupported variable rejected + +- **WHEN** the SAN string references `$FLEET_VAR_HOST_PLATFORM` (defined globally but not in the cert-template allow-list), or + `$FLEET_VAR_HOST_END_USER_IDP_GROUPS`, or any other `FLEET_VAR_*` not in the allow-list above +- **THEN** the create call SHALL fail with a 422 invalid-argument error +- **AND** the error message SHALL identify both the offending variable and that it was found in `subject_alternative_name` + +### Requirement: Expand variables in SAN at delivery time + +When the device-facing certificate template endpoint returns a template for a specific host, the system SHALL expand +`$FLEET_VAR_HOST_*` references in `subject_alternative_name` using the host's values, with semantics identical to the existing +expansion of `subject_name`. + +#### Scenario: Successful expansion for both fields + +- **GIVEN** a host with UUID `H-123` and IdP username `marko@example.com` +- **AND** a template with `subject_name = "/CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"` and + `subject_alternative_name = "DNS=example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"` +- **WHEN** the agent fetches the template for that host +- **THEN** the response SHALL contain `subject_name = "/CN=marko@example.com"` +- **AND** `subject_alternative_name = "DNS=example.com, UPN=marko@example.com"` + +#### Scenario: Host missing data for SAN variable + +- **GIVEN** a host without an IdP username +- **AND** a template whose SAN references `$FLEET_VAR_HOST_END_USER_IDP_USERNAME` +- **WHEN** delivery is attempted +- **THEN** the system SHALL transition the host's certificate template state to `failed` +- **AND** the failure SHALL surface the same way as a `subject_name` expansion failure (no silent issuance) + +#### Scenario: Empty SAN passes through unchanged + +- **WHEN** the template has no `subject_alternative_name` (NULL in storage) +- **THEN** the device-facing response SHALL omit the `subject_alternative_name` key entirely (per `omitempty`), and delivery + SHALL behave exactly as it does today for templates without SAN + +### Requirement: Android Fleet agent includes SAN extension in PKCS#10 CSR + +The Android Fleet agent (Kotlin source under `android/app/src/main/java/com/fleetdm/agent/`) SHALL parse the +`subject_alternative_name` field returned by `/api/fleetd/certificates/{id}` and SHALL include a `subjectAlternativeName` +extension (OID `2.5.29.17`) in the PKCS#10 Certificate Signing Request it submits to the SCEP CA. The extension SHALL be +attached as part of the CSR's `extensionRequest` attribute (PKCS#9 OID `1.2.840.113549.1.9.14`), with `critical = false` +hard-coded (per RFC 5280 §4.2.1.6, since `subject_name` is always non-empty in Fleet). The extension SHALL contain one +`GeneralName` entry per parsed token, in document order. The agent SHALL recognize five KEYs (case-insensitive): `DNS`, +`EMAIL`, `UPN`, `IP`, `URI`. These are the SAN attribute types real-world enterprise PKI actually deploys for the use cases +this feature targets (Wi-Fi/EAP-TLS, internal mTLS, S/MIME, modern service identity). + +Multiple values of the same KEY are allowed. Each `KEY=value` token in the comma-separated string produces one +`GeneralName` entry, so `"DNS=a.example.com, DNS=b.example.com, EMAIL=alice@x, EMAIL=alice@y"` produces four entries (two +`dNSName`, two `rfc822Name`) preserving the order they were written. This matches RFC 5280 §4.2.1.6, which permits any number +of `GeneralName` entries of the same type. + +The encoding for each KEY: + +- `DNS` -> `GeneralName.dNSName` with `DERIA5String(value)`. BouncyCastle tag 2. +- `EMAIL` -> `GeneralName.rfc822Name` with `DERIA5String(value)`. BouncyCastle tag 1. (Internal X.509 type is `rfc822Name`; + the user-facing key is `EMAIL=`. `RFC822=` is not accepted as a synonym.) +- `URI` -> `GeneralName.uniformResourceIdentifier` with `DERIA5String(value)`. BouncyCastle tag 6. +- `IP` -> `GeneralName.iPAddress`, BouncyCastle tag 7. The agent SHALL parse the value as IPv4 dotted-quad or IPv6 + colon-hex (e.g. via `InetAddress.getByName(value)`) and emit a `DEROctetString` containing the raw 4-byte (IPv4) or 16-byte + (IPv6) address. Values that fail to parse SHALL cause the agent to hard-fail (see scenario below). +- `UPN` -> `GeneralName.otherName` (BouncyCastle tag 0) carrying an `OtherName` SEQUENCE: + `{ type-id ASN1ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), value [0] EXPLICIT DERUTF8String(value) }` per Microsoft KB258605 + / RFC 4556 §3.2.1. The `[0] EXPLICIT` tag on the value is required — without it, the resulting `otherName` is + uninterpretable to Windows / NPS / Intune supplicants. + +#### Scenario: SAN absent — CSR unchanged from current behavior + +- **GIVEN** a template whose response contains no `subject_alternative_name` (null or empty) +- **WHEN** the agent builds the CSR +- **THEN** the CSR SHALL NOT carry an `extensionRequest` attribute *for SAN* (the existing challenge-password attribute is + still present) +- **AND** the resulting cert SHALL be byte-identical (modulo timestamps and serial) to what the current agent produces today + +#### Scenario: SAN with a single DNS entry + +- **GIVEN** the response carries `subject_alternative_name = "DNS=wifi.example.com"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR SHALL contain exactly one SAN extension whose only entry is a `dNSName` with value `wifi.example.com` + +#### Scenario: SAN with a UPN entry encoded as OtherName + +- **GIVEN** the response carries `subject_alternative_name = "UPN=marko@corp.example.com"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain one `otherName` with type-id OID `1.3.6.1.4.1.311.20.2.3` +- **AND** the `value` shall be a `DERUTF8String` whose contents decode to `marko@corp.example.com` + +#### Scenario: SAN with IPv4 entry + +- **GIVEN** the response carries `subject_alternative_name = "IP=10.0.0.1"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain exactly one `iPAddress` entry whose 4-byte octet string is `0a 00 00 01` + +#### Scenario: SAN with IPv6 entry + +- **GIVEN** the response carries `subject_alternative_name = "IP=2001:db8::1"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain exactly one `iPAddress` entry whose 16-byte octet string corresponds to the + parsed IPv6 address + +#### Scenario: SAN with URI entry + +- **GIVEN** the response carries `subject_alternative_name = "URI=spiffe://example.com/workload/payments"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain exactly one `uniformResourceIdentifier` entry with that exact value + +#### Scenario: SAN with mixed entries + +- **GIVEN** `subject_alternative_name = "DNS=wifi.example.com, UPN=marko@corp.example.com, EMAIL=marko@corp.example.com, + IP=10.0.0.1, URI=spiffe://example.com/workload/wifi"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain five entries in document order: a `dNSName`, an `otherName` (UPN), an + `rfc822Name`, an `iPAddress`, and a `uniformResourceIdentifier` +- **AND** all five values decode to the values the server returned + +#### Scenario: SAN with multiple values of the same KEY + +- **GIVEN** `subject_alternative_name = "DNS=primary.example.com, DNS=secondary.example.com, EMAIL=alice@x.example.com, + EMAIL=alice@y.example.com"` +- **WHEN** the agent builds the CSR +- **THEN** the CSR's SAN extension SHALL contain four entries in document order: two `dNSName`, then two `rfc822Name` +- **AND** the same principle SHALL apply to repeated `UPN`, `IP`, and `URI` keys (each repetition produces one additional + `GeneralName` entry of the corresponding type) + +#### Scenario: SAN with unknown KEY -> hard fail + +- **GIVEN** `subject_alternative_name = "FOO=bar"` or `"RFC822=user@x"` (not a synonym for `EMAIL=`) +- **WHEN** the agent builds the CSR +- **THEN** the agent SHALL throw a `ScepCsrException` (or equivalent) naming the offending KEY +- **AND** the agent SHALL NOT submit a CSR to the SCEP CA +- **AND** the certificate template's host-side state SHALL surface the failure in the host details "OS settings" modal (no + silent issuance of a cert lacking the intended SAN), per the Figma dev note + +#### Scenario: SAN with unparseable IP -> hard fail + +- **GIVEN** `subject_alternative_name = "IP=not.an.address"` +- **WHEN** the agent builds the CSR +- **THEN** the agent SHALL throw with a clear message indicating the value could not be parsed as IPv4 or IPv6 +- **AND** the agent SHALL NOT submit a CSR + +#### Scenario: SAN with malformed token -> hard fail + +- **GIVEN** `subject_alternative_name` contains a token without `=` (e.g. `"DNS=ok, UPN"`) +- **WHEN** the agent builds the CSR +- **THEN** the agent SHALL throw with a clear message identifying the bad token +- **AND** the agent SHALL NOT submit a CSR + +#### Scenario: Whitespace tolerance + +- **WHEN** the SAN string contains leading/trailing whitespace around tokens or around `=` + (e.g. `" DNS = a.example.com , UPN= marko@x "`) +- **THEN** the agent SHALL trim whitespace from each KEY and value before building `GeneralName`, producing the same CSR as + the un-spaced form + +### Requirement: GitOps apply round-trips SAN + +GitOps YAML under `controls.android_settings.certificates[]` SHALL accept an optional `subject_alternative_name` key per entry +and apply it to the corresponding certificate template. Validation matches the REST API: same variable allow-list, same +empty-vs-NULL normalization. + +#### Scenario: Apply YAML with SAN + +- **WHEN** a GitOps run applies a certificates entry whose YAML contains + `subject_alternative_name: "DNS=example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"` +- **THEN** the resulting certificate template SHALL have that value persisted +- **AND** subsequent device delivery SHALL render it as in "Expand variables in SAN at delivery time" + +#### Scenario: Apply YAML without SAN + +- **WHEN** a GitOps run applies a certificates entry that omits `subject_alternative_name` +- **THEN** the resulting certificate template's `subject_alternative_name` SHALL be NULL +- **AND** no validation warning SHALL fire + +### Requirement: generate-gitops emits SAN + +`fleetctl generate-gitops` SHALL include a `subject_alternative_name` key for each certificate template whose stored value is +non-NULL. Templates with NULL SAN SHALL omit the key entirely so existing GitOps files do not pick up spurious diffs. + +#### Scenario: Round-trip with SAN + +- **GIVEN** a template stored with `subject_alternative_name = "DNS=example.com"` +- **WHEN** an admin runs `fleetctl generate-gitops` +- **THEN** the emitted YAML for that certificates entry contains `subject_alternative_name: "DNS=example.com"` +- **AND** re-applying the emitted YAML produces an identical template (idempotent round-trip) + +#### Scenario: Round-trip without SAN + +- **GIVEN** a template stored with NULL `subject_alternative_name` +- **WHEN** an admin runs `fleetctl generate-gitops` +- **THEN** the emitted YAML for that certificates entry SHALL NOT contain the `subject_alternative_name` key + +### Requirement: Add/Edit Certificate UI exposes SAN + +The "Add certificate" / "Edit certificate" modal in Manage > Controls > OS Settings > Certificates SHALL include an optional +text input labeled per the Figma wireframe (node 3462-252) that maps to `subject_alternative_name`. The input SHALL be visible +only when the existing certificate-template feature is available (i.e. for Premium tenants), with no separate gate. + +#### Scenario: Submit form with SAN + +- **WHEN** an admin opens the modal, fills the SAN input with a valid value, and submits +- **THEN** the request payload SHALL include `subject_alternative_name` with the entered value +- **AND** on success, the certificates list SHALL show the new template + +#### Scenario: Submit form without SAN + +- **WHEN** an admin submits the modal without filling the SAN input +- **THEN** the request SHALL succeed and the persisted template SHALL have NULL `subject_alternative_name` + +#### Scenario: Server validation surfaces in form + +- **WHEN** the server returns a 422 because the SAN references an unsupported variable +- **THEN** the form SHALL display the server-provided error against the SAN input + +### Requirement: Add Certificate modal uses always-enabled-Add with on-submit required-field highlighting + +The Add/Edit Certificate modal SHALL keep its primary "Add" button enabled whenever a submit is not already in flight, +regardless of whether required fields are filled. Clicking "Add" while required fields (Name, Certificate authority, +Subject name) are empty or invalid SHALL surface an inline " must be completed" error beneath each offending +required field and SHALL NOT call the create API. Once all required fields are valid the next "Add" click SHALL submit +normally. `subject_alternative_name` is optional and SHALL NOT participate in this required-field highlighting flow. This +matches the Figma node 2:130 dev note ("Add button is always enabled. If user hit Add without all required fields, then +light up required fields"). + +#### Scenario: Click Add with all required fields empty + +- **GIVEN** the Add Certificate modal is open and Name, Certificate authority, and Subject name are all empty +- **WHEN** the admin clicks "Add" +- **THEN** the create API SHALL NOT be called +- **AND** an inline error SHALL be displayed beneath each of the three required fields naming the field +- **AND** the Add button SHALL remain enabled + +#### Scenario: Click Add with one required field empty + +- **GIVEN** Name and Subject name are filled, Certificate authority is unselected +- **WHEN** the admin clicks "Add" +- **THEN** the create API SHALL NOT be called +- **AND** the inline error SHALL appear beneath the Certificate authority field only + +#### Scenario: Click Add with only optional SAN unfilled + +- **GIVEN** Name, Certificate authority, and Subject name are all valid; `subject_alternative_name` is empty +- **WHEN** the admin clicks "Add" +- **THEN** the create API SHALL be called and the modal SHALL close on success +- **AND** no required-field error SHALL appear for `subject_alternative_name` + +#### Scenario: Fix required fields after submit-with-empty + +- **GIVEN** the admin clicked "Add" with empty required fields and the inline errors are displayed +- **WHEN** the admin fills the required fields and clicks "Add" again +- **THEN** the create API SHALL be called and the modal SHALL close on success + +### Requirement: Premium gating for certificate templates + +The server SHALL reject any create or GitOps apply of a certificate template — with or without +`subject_alternative_name` — when the deployment is not Fleet Premium, returning `fleet.ErrMissingLicense` (HTTP 402). The +check MUST sit at the top of the service method, after authorization, before validation. + +Rationale: certificate templates require a custom SCEP CA, and CAs are documented and implemented as Premium-only (see +`server/service/certificate_authorities.go` core stubs that return `fleet.ErrMissingLicense`). The whole feature is therefore +Premium-only by construction. + +The `fleetctl gitops` client SHALL perform an equivalent pre-flight Premium check via `c.GetAppConfig()` whenever the YAML +declares one or more android certificates, so Free admins get a friendly error before any destructive operation runs against +the team. Free admins whose YAML omits the certificates section (or sets it to an empty list) and whose team has no existing +templates SHALL succeed with no changes. + +#### Scenario: Non-Premium attempts to create a certificate template (with or without SAN) + +- **WHEN** a non-Premium tenant POSTs a certificate template, regardless of whether `subject_alternative_name` is set +- **THEN** the server SHALL reject the request with `fleet.ErrMissingLicense` (HTTP 402) +- **AND** no row SHALL be written to `certificate_templates` + +#### Scenario: Non-Premium GitOps apply with certificates declared + +- **WHEN** a non-Premium tenant runs `fleetctl gitops` against a YAML that declares one or more + `controls.android_settings.certificates` +- **THEN** the client SHALL fail with a `gitOpsValidationError` whose message states that Android certificate templates + require a custom SCEP CA and are available in Fleet Premium only +- **AND** no apply request SHALL be sent to the server + +#### Scenario: Non-Premium GitOps apply with no certificates declared + +- **WHEN** a non-Premium tenant runs `fleetctl gitops` against a YAML that omits the certificates section (or has an empty + `certificates: []`) and the team has no existing certificate templates +- **THEN** the GitOps apply SHALL succeed with no errors and no Premium check SHALL fire (the cert-template flow short- + circuits before any server call) diff --git a/openspec/changes/android-cert-san-attributes/tasks.md b/openspec/changes/android-cert-san-attributes/tasks.md new file mode 100644 index 00000000000..7e487cb410d --- /dev/null +++ b/openspec/changes/android-cert-san-attributes/tasks.md @@ -0,0 +1,149 @@ +## 1. Backend types and database + +- [x] 1.1 Add `SubjectAlternativeName string` to `fleet.CertificateTemplate`, `fleet.CertificateRequestSpec`, + `CertificateTemplateResponseSummary`, `CertificateTemplateResponse`, and `CertificateTemplateResponseForHost` in + `server/fleet/certificate_templates.go` with `json:"subject_alternative_name,omitempty"` and `db:"subject_alternative_name"` +- [x] 1.2 Run `make migration name=AddSubjectAlternativeNameToCertificateTemplates`, edit the generated migration to add a + nullable `subject_alternative_name TEXT` column to `certificate_templates` (matching the existing `subject_name TEXT` + column type, per `server/datastore/mysql/migrations/tables/20251124140138_CreateTableCertifcatesTemplates.go`) +- [x] 1.3 Update the datastore layer (`server/datastore/mysql/certificate_templates.go`) to read and write + `subject_alternative_name` in the create / get-by-id / list / get-for-host queries. **Whitespace policy:** if + `strings.TrimSpace(value) == ""`, store NULL; otherwise store the original (non-trimmed) value verbatim — preserves + admin intent and keeps GitOps round-trips idempotent. + +## 2. Service layer: validation, variable expansion, Premium gate + +- [x] 2.1 In `server/service/certificates.go`, add `SubjectAlternativeName *string` to `createCertificateTemplateRequest` + (pointer so we can distinguish "omitted" from "empty") and pass it into the service method + _(implemented as plain `string`; both omitted and empty deserialize to "" and store as NULL — pointer not needed)_ +- [x] 2.2 Update `Service.CreateCertificateTemplate` to accept the SAN argument, call + `validateCertificateTemplateFleetVariables` on it, and pass through to the datastore. The service does **not** mutate + non-empty values; whitespace-only input is normalized to NULL at the datastore layer (see 1.4). +- [x] 2.2a Implement lightweight SAN format validation per the "Lightweight server-side validation" decision in design.md: + every non-empty comma-separated token contains exactly one `=`; KEY (case-insensitive) is in the allow-list + `{DNS, EMAIL, UPN, IP, URI}`; total SAN string is under 4096 bytes. Failures return a 422 invalid-argument error against + the `subject_alternative_name` field with a message naming the offending token / KEY. **Confirm with the designer + before implementing** — the Figma dev note says "we won't validate SAN" and this deviates by adding format-only checks + (not value content). If the designer rejects, drop this task and let the agent be the only gatekeeper. +- [x] 2.3 If a Premium check is not already enforced upstream of `CreateCertificateTemplate`, add `svc.License.IsPremium()` + gating consistent with how other Premium-only writes work, returning the standard Premium-required error + _(scoped to SAN-bearing payloads only; the broader feature-wide gate is deferred — see design.md Premium decision)_ +- [x] 2.4 Extend `Service.replaceCertificateVariables` (or its caller in `GetDeviceCertificateTemplate`) to render + `subject_alternative_name` for the host with the same error semantics as `subject_name`; on failure the cert state + transitions to `failed` +- [x] 2.5 Add unit tests in `server/service/certificate_templates_test.go` covering: SAN with supported variable, SAN with + unsupported variable (rejected), empty SAN (NULL persisted), whitespace-only SAN (NULL persisted), variable expansion + success, expansion failure for missing host data, and Premium gate + +## 3. Device-facing endpoint plumbs SAN through to Android agent + +- [x] 3.1 Confirm `GetDeviceCertificateTemplate` (the per-host endpoint behind `/api/fleetd/certificates/{id}` that the Android + Fleet agent reads) returns the expanded `subject_alternative_name` whenever non-empty, omits it (or returns `""`) + otherwise +- [x] 3.2 Add an integration test in `server/service/integration_*_test.go` that creates a template with SAN, simulates the + Android agent fetch for a host with an IdP username, and asserts the rendered SAN comes back correctly + +## 4. Android Fleet agent (Kotlin, `android/app/src/main/`) + +- [ ] 4.1 In `android/app/src/main/java/com/fleetdm/agent/ApiClient.kt`, add `@SerialName("subject_alternative_name") val subjectAlternativeName: + String? = null` to `GetCertificateTemplateResponse` (around line 588-626). Confirm `Json` is configured with + `ignoreUnknownKeys = true` so older agents on newer servers continue to deserialize. +- [ ] 4.2 Create `android/app/src/main/java/com/fleetdm/agent/scep/SubjectAlternativeNameParser.kt` with a single public function + `parse(sanString: String?): GeneralNames?` per the "Android agent" decision in design.md. Returns `null` for + null/empty/whitespace; throws + `IllegalArgumentException` (caller wraps to `ScepCsrException`) for unknown KEY, malformed tokens, or unparseable IP + values. Supports five KEYs: `DNS` (`dNSName`), `EMAIL` (`rfc822Name`), `URI` (`uniformResourceIdentifier`), `IP` + (`iPAddress`, parse via `InetAddress.getByName` to 4-/16-byte octet string), `UPN` (`otherName` with OID + `1.3.6.1.4.1.311.20.2.3`, value as `DERUTF8String` wrapped in `[0] EXPLICIT` per Microsoft KB258605). +- [ ] 4.3 Update `android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt#buildCsr` (around line 168-179) to call the parser and, when it + returns non-null `GeneralNames`, append an `extensionRequest` attribute (`pkcs_9_at_extensionRequest`) carrying a + single `Extension` for `subjectAlternativeName` with `critical = false`. Wrap parser exceptions in `ScepCsrException` + so the existing failure-path observability is preserved. +- [ ] 4.4 Update `android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt` if needed to carry `subjectAlternativeName` from + `GetCertificateTemplateResponse` into the SCEP client config (no logic change beyond plumbing the new field). +- [ ] 4.5 Update `android/app/src/test/java/com/fleetdm/agent/testutil/TestCertificateTemplateFactory.kt` to accept an optional + `subjectAlternativeName: String? = null` parameter on `create(...)` so existing tests keep passing while new tests can + opt in. +- [ ] 4.6 Add `android/app/src/test/java/com/fleetdm/agent/scep/SubjectAlternativeNameParserTest.kt` covering: null input, + whitespace-only input, single DNS, single EMAIL, single URI, single IP (IPv4 dotted-quad), single IP (IPv6 colon-hex), + single UPN (decode the produced `OtherName` and assert OID + UTF8 value), mixed entries (all five KEYs), repeated keys + across multiple types (e.g. two DNS + two EMAIL → four entries in document order, asserting the same applies to + repeated UPN / IP / URI), unknown KEY (expects throw), `RFC822=` rejected as unknown KEY (expects throw), malformed + token (expects throw), unparseable IP (expects throw), whitespace tolerance. +- [ ] 4.7 Extend `android/app/src/test/java/com/fleetdm/agent/scep/ScepClientImplTest.kt` with cases that build a CSR with various SAN + strings and decode the resulting CSR to assert the SAN extension is present, non-critical, and contains the expected + `GeneralName` entries. Also keep an explicit regression test for "no SAN -> no SAN extension". +- [ ] 4.8 Extend `android/app/src/test/java/com/fleetdm/agent/CertificateEnrollmentHandlerTest.kt` to assert the SAN string flows from + the API response through to the SCEP config that `MockScepClient` captures. +- [ ] 4.9 Run `./gradlew test` (or the project's standard test command — see `android/CHANGELOG.md` and `android/README.md`) + and confirm green. +- [ ] 4.10 Add an entry to `android/CHANGELOG.md` for the SAN-extension behavior change. No new third-party dep is needed + (BouncyCastle 1.78.1 already on classpath via `bcprov-jdk18on` + `bcpkix-jdk18on`). + +## 5. GitOps + +- [x] 5.1 Add `SubjectAlternativeName string` (with `yaml:"subject_alternative_name,omitempty"`) to + `fleet.CertificateTemplateSpec` in `server/fleet/app.go` +- [x] 5.2 Update GitOps validation in `pkg/spec/gitops.go` (and `gitops_validate.go` if applicable) to validate variables in SAN + via the same helper used for `subject_name` + _(validation lives in `server/service/client.go` for friendly fleetctl errors and on the server as the source of truth; + `pkg/spec/gitops.go` got a clarifying comment — required-field checks stay as-is since SAN is optional)_ +- [x] 5.3 Update the Apply path so the spec's SAN is forwarded to `CreateCertificateTemplate` +- [x] 5.4 Add a GitOps round-trip test in `cmd/fleetctl` covering both a certificate with SAN and one without + _(covered by the existing `TestGenerateGitops` `compareDirs` against `testdata/generateGitops/test_dir_premium`; the + fixture and `MockClient.GetCertificateTemplates` were extended to include SAN, so the round-trip now exercises both + cases)_ + +## 6. GitOps generate + +- [x] 6.1 Update `cmd/fleetctl/generate_gitops.go` (around the certificates emit block) to include + `subject_alternative_name` for each template whose stored value is non-NULL, omit the key entirely otherwise +- [x] 6.2 Add or extend the existing generate-gitops golden-file test for the new field, covering both populated and empty + cases + _(extended `teamConfig.json`, `expectedTeamControls.yaml`, and `test_dir_premium/fleets/team-a-thumbsup.yml`; + `MockClient.GetCertificateTemplates` returns SAN; populated-emit and empty-omit paths both exercised by the existing + tests)_ + +## 7. Frontend + +- [ ] 7.1 Add `subjectAlternativeName: string` to `IAddCertFormData` under + `frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/`. (No edit-form types: the Certificates card today + wires only Add and Delete actions in `Certificates.tsx`; editing an existing template is delete+re-add.) +- [ ] 7.2 Add a SAN text input directly under the existing `subject_name` input in `AddCertificateModal.tsx`, matching the + Figma wireframe (https://www.figma.com/design/2jRQoXofC1caxyNhWl8F0m/...?node-id=3462-252) for label/help text +- [ ] 7.3 Wire the field through the API client (`frontend/services/`) request and response types so the modal sends and reads + `subject_alternative_name` +- [ ] 7.4 Surface server-side validation errors against the SAN input (the existing pattern for `subject_name` errors) +- [ ] 7.5 Switch `AddCertificateModal.tsx` to the always-enabled-Add pattern per the Figma's second dev note: replace + `disabled={!formValidation.isValid || isUpdating}` (currently around line 187) with `disabled={isUpdating}`. Add an + `attemptedSubmit` boolean state that flips to `true` on the first click of "Add". The submit handler short-circuits + (does not call the API) when `attemptedSubmit && !formValidation.isValid`. Pass the `attemptedSubmit` flag down to each + required field's `` / `` so it renders the existing validation error inline (e.g. "Name + must be completed", "Certificate authority must be completed", "Subject name must be completed"). Remove the existing + tooltip on the disabled button (`disableTooltip` / `TooltipWrapper` around the Add button) — it no longer applies. + Note: this is a modal-wide change, not SAN-specific. +- [ ] 7.6 Add a "field must be completed" message for any required field that does not already have one (today some required + fields rely solely on the disabled-button-with-tooltip to communicate). Confirm against the Figma example screenshot + ("Add user" modal) for exact phrasing per field. +- [ ] 7.7 Add Jest tests for the modal: (a) submitting with SAN, (b) submitting without SAN, (c) server error on SAN + surfaces inline against the SAN field, (d) clicking Add with all required fields empty surfaces three inline errors and + does not call the API, (e) clicking Add with one required field empty surfaces exactly that field's error, (f) the Add + button is enabled at all times except while `isUpdating`. + +## 8. Documentation and rollout + +- [ ] 8.1 Update the feature guide at https://fleetdm.com/guides/connect-end-user-to-wifi-with-certificate#android-deploy-certificate + to document the new SAN field, supported variables, and an end-to-end example for Wi-Fi UPN +- [x] 8.2 Verify the REST API docs already merged in PR #43318 still match the implemented contract (field name, + example payload); fix if drift exists + _(verified: implemented field name `subject_alternative_name` matches the docs; example payloads use the same comma- + separated format)_ +- [ ] 8.3 Run the test plan from the issue: deliver a certificate with each of the SANs in the supplied test cert fixtures, + confirm the resulting cert on the device contains the SAN extension and that EAP-TLS authentication succeeds against + the reference test CA +- [ ] 8.4 Rollout order per design.md Migration Plan: the Android Fleet agent build (section 4 of this list) ships *first*, + and only after it has rolled out broadly does the Fleet server expose SAN to admins (frontend modal, generate-gitops + emit, REST docs visibility). The agent build is forward-compatible against older servers, so it can land independently. + If timing forces overlapping releases, hide the UI input and the generate-gitops emit behind a feature flag until agent + coverage is confirmed. +- [ ] 8.5 Engineer comment on issue #41472 confirming successful test plan completion (per the issue's Confirmation section) diff --git a/server/datastore/mysql/certificate_templates.go b/server/datastore/mysql/certificate_templates.go index 4491dfabd47..70db9d8eadc 100644 --- a/server/datastore/mysql/certificate_templates.go +++ b/server/datastore/mysql/certificate_templates.go @@ -20,12 +20,23 @@ var certificateTemplateAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ "id": "certificate_templates.id", } +// subjectAlternativeNameForStorage returns the value to bind for the subject_alternative_name column. +// Whitespace-only and empty values are stored as SQL NULL; non-empty values are stored verbatim +// (no per-token trimming) to preserve admin intent and keep GitOps round-trips idempotent. +func subjectAlternativeNameForStorage(san string) any { + if strings.TrimSpace(san) == "" { + return nil + } + return san +} + const certificateTemplateResponseSql = ` SELECT certificate_templates.id, certificate_templates.name, certificate_templates.team_id, certificate_templates.subject_name, + COALESCE(certificate_templates.subject_alternative_name, '') AS subject_alternative_name, certificate_templates.created_at, certificate_authorities.id AS certificate_authority_id, certificate_authorities.name AS certificate_authority_name, @@ -90,6 +101,7 @@ func (ds *Datastore) GetCertificateTemplateByIdForHost(ctx context.Context, id u certificate_templates.name, certificate_templates.team_id, certificate_templates.subject_name, + COALESCE(certificate_templates.subject_alternative_name, '') AS subject_alternative_name, certificate_templates.created_at, certificate_authorities.id AS certificate_authority_id, certificate_authorities.name AS certificate_authority_name, @@ -147,6 +159,7 @@ func (ds *Datastore) GetCertificateTemplatesByTeamID(ctx context.Context, teamID certificate_templates.id, certificate_templates.name, certificate_templates.subject_name, + COALESCE(certificate_templates.subject_alternative_name, '') AS subject_alternative_name, certificate_templates.certificate_authority_id, certificate_authorities.name AS certificate_authority_name, certificate_templates.created_at @@ -180,14 +193,17 @@ func (ds *Datastore) GetCertificateTemplatesByTeamID(ctx context.Context, teamID } func (ds *Datastore) CreateCertificateTemplate(ctx context.Context, certificateTemplate *fleet.CertificateTemplate) (*fleet.CertificateTemplateResponse, error) { + sanArg := subjectAlternativeNameForStorage(certificateTemplate.SubjectAlternativeName) result, err := ds.writer(ctx).ExecContext(ctx, ` INSERT INTO certificate_templates ( name, team_id, certificate_authority_id, - subject_name - ) VALUES (?, ?, ?, ?) - `, certificateTemplate.Name, certificateTemplate.TeamID, certificateTemplate.CertificateAuthorityID, certificateTemplate.SubjectName) + subject_name, + subject_alternative_name + ) VALUES (?, ?, ?, ?, ?) + `, certificateTemplate.Name, certificateTemplate.TeamID, certificateTemplate.CertificateAuthorityID, + certificateTemplate.SubjectName, sanArg) if err != nil { if IsDuplicate(err) { return nil, ctxerr.Wrap(ctx, alreadyExists("CertificateTemplate", certificateTemplate.Name), "inserting certificate_template") @@ -200,11 +216,17 @@ func (ds *Datastore) CreateCertificateTemplate(ctx context.Context, certificateT return nil, ctxerr.Wrap(ctx, err, "getting last insert id for certificate_template") } + storedSAN := "" + if sanArg != nil { + storedSAN = certificateTemplate.SubjectAlternativeName + } + return &fleet.CertificateTemplateResponse{ CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{ ID: uint(id), //nolint:gosec Name: certificateTemplate.Name, SubjectName: certificateTemplate.SubjectName, + SubjectAlternativeName: storedSAN, CertificateAuthorityId: certificateTemplate.CertificateAuthorityID, }, TeamID: certificateTemplate.TeamID, @@ -236,13 +258,17 @@ func (ds *Datastore) BatchUpsertCertificateTemplates(ctx context.Context, certif return nil, nil } + // On duplicate (team_id, name), this is a no-op for content-bearing fields. SubjectName, + // CertificateAuthorityID, and SubjectAlternativeName changes are handled upstream, so the + // upsert intentionally does not propagate updates. const sqlInsertCertificate = ` INSERT INTO certificate_templates ( name, team_id, certificate_authority_id, - subject_name - ) VALUES (?, ?, ?, ?) + subject_name, + subject_alternative_name + ) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), team_id = VALUES(team_id) @@ -250,7 +276,9 @@ func (ds *Datastore) BatchUpsertCertificateTemplates(ctx context.Context, certif teamsModifiedSet := make(map[uint]struct{}) for _, cert := range certificateTemplates { - result, err := ds.writer(ctx).ExecContext(ctx, sqlInsertCertificate, cert.Name, cert.TeamID, cert.CertificateAuthorityID, cert.SubjectName) + sanArg := subjectAlternativeNameForStorage(cert.SubjectAlternativeName) + result, err := ds.writer(ctx).ExecContext(ctx, sqlInsertCertificate, + cert.Name, cert.TeamID, cert.CertificateAuthorityID, cert.SubjectName, sanArg) if err != nil { return nil, ctxerr.Wrap(ctx, err, "upserting certificate_template") } diff --git a/server/datastore/mysql/migrations/tables/20260504193725_AddSubjectAlternativeNameToCertificateTemplates.go b/server/datastore/mysql/migrations/tables/20260504193725_AddSubjectAlternativeNameToCertificateTemplates.go new file mode 100644 index 00000000000..a020db7c5ac --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260504193725_AddSubjectAlternativeNameToCertificateTemplates.go @@ -0,0 +1,26 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260504193725, Down_20260504193725) +} + +func Up_20260504193725(tx *sql.Tx) error { + _, err := tx.Exec(` + ALTER TABLE certificate_templates + ADD COLUMN subject_alternative_name TEXT + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL + `) + if err != nil { + return fmt.Errorf("add subject_alternative_name column to certificate_templates: %w", err) + } + return nil +} + +func Down_20260504193725(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index d184b41205d..f7a9bb13f7b 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -365,6 +365,7 @@ CREATE TABLE `certificate_templates` ( `subject_name` text COLLATE utf8mb4_unicode_ci NOT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `subject_alternative_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, PRIMARY KEY (`id`), UNIQUE KEY `idx_cert_team_name` (`team_id`,`name`), KEY `certificate_authority_id` (`certificate_authority_id`), @@ -1970,9 +1971,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=522 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=523 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260427134220,1,'2020-01-01 01:01:01'),(519,20260428125634,1,'2020-01-01 01:01:01'),(520,20260429180725,1,'2020-01-01 01:01:01'),(521,20260430103635,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260427134220,1,'2020-01-01 01:01:01'),(519,20260428125634,1,'2020-01-01 01:01:01'),(520,20260429180725,1,'2020-01-01 01:01:01'),(521,20260430103635,1,'2020-01-01 01:01:01'),(522,20260504193725,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/app.go b/server/fleet/app.go index 71a3928bfb3..ea0576aa7bd 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1902,6 +1902,7 @@ type CertificateTemplateSpec struct { Name string `json:"name"` CertificateAuthorityName string `json:"certificate_authority_name"` SubjectName string `json:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty"` } func (c CertificateTemplateSpec) NameValid() bool { diff --git a/server/fleet/certificate_templates.go b/server/fleet/certificate_templates.go index 8d90a46a136..5ecd8998454 100644 --- a/server/fleet/certificate_templates.go +++ b/server/fleet/certificate_templates.go @@ -5,6 +5,7 @@ type CertificateRequestSpec struct { Team string `json:"team,omitempty" renameto:"fleet"` CertificateAuthorityId uint `json:"certificate_authority_id"` SubjectName string `json:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty"` } type CertificateTemplate struct { @@ -12,6 +13,7 @@ type CertificateTemplate struct { TeamID uint `json:"team_id" renameto:"fleet_id"` CertificateAuthorityID uint `json:"certificate_authority_id"` SubjectName string `json:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty"` } func (c *CertificateTemplate) AuthzType() string { @@ -22,6 +24,7 @@ type CertificateTemplateResponseSummary struct { ID uint `json:"id" db:"id"` Name string `json:"name" db:"name"` SubjectName string `json:"subject_name" db:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty" db:"subject_alternative_name"` CertificateAuthorityId uint `json:"certificate_authority_id" db:"certificate_authority_id"` CertificateAuthorityName string `json:"certificate_authority_name" db:"certificate_authority_name"` CreatedAt string `json:"created_at" db:"created_at"` diff --git a/server/fleet/service.go b/server/fleet/service.go index 2a2cea63eb7..cd7a68f6119 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -693,7 +693,7 @@ type Service interface { // ///////////////////////////////////////////////////////////////////////////// // Certificate Templates - CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string) (*CertificateTemplateResponse, error) + CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string, subjectAlternativeName string) (*CertificateTemplateResponse, error) ListCertificateTemplates(ctx context.Context, teamID uint, opts ListOptions) ([]*CertificateTemplateResponseSummary, *PaginationMetadata, error) GetDeviceCertificateTemplate(ctx context.Context, id uint) (*CertificateTemplateResponseForHost, error) GetCertificateTemplate(ctx context.Context, id uint) (*CertificateTemplateResponse, error) diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 9eff4866fce..b65225b202a 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -414,7 +414,7 @@ type CancelHostUpcomingActivityFunc func(ctx context.Context, hostID uint, execu type ApplyUserRolesSpecsFunc func(ctx context.Context, specs fleet.UsersRoleSpec) error -type CreateCertificateTemplateFunc func(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string) (*fleet.CertificateTemplateResponse, error) +type CreateCertificateTemplateFunc func(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string, subjectAlternativeName string) (*fleet.CertificateTemplateResponse, error) type ListCertificateTemplatesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) @@ -3663,11 +3663,11 @@ func (s *Service) ApplyUserRolesSpecs(ctx context.Context, specs fleet.UsersRole return s.ApplyUserRolesSpecsFunc(ctx, specs) } -func (s *Service) CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string) (*fleet.CertificateTemplateResponse, error) { +func (s *Service) CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string, subjectAlternativeName string) (*fleet.CertificateTemplateResponse, error) { s.mu.Lock() s.CreateCertificateTemplateFuncInvoked = true s.mu.Unlock() - return s.CreateCertificateTemplateFunc(ctx, name, teamID, certificateAuthorityID, subjectName) + return s.CreateCertificateTemplateFunc(ctx, name, teamID, certificateAuthorityID, subjectName, subjectAlternativeName) } func (s *Service) ListCertificateTemplates(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { diff --git a/server/service/certificate_templates.go b/server/service/certificate_templates.go index 390f103bd21..8c6cf31cc90 100644 --- a/server/service/certificate_templates.go +++ b/server/service/certificate_templates.go @@ -4,19 +4,35 @@ import ( "context" "fmt" "slices" + "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/variables" ) -// Fleet variables supported in certificate template subject names. +// Fleet variables supported in certificate template subject names and SANs. var fleetVarsSupportedInCertificateTemplates = []fleet.FleetVarName{ fleet.FleetVarHostUUID, fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, } +// maxCertificateTemplateSubjectAlternativeNameLength caps the SAN string length to prevent +// pathological inputs. 4096 bytes accommodates real-world SAN lists (a handful of DNS / UPN / +// EMAIL / IP / URI entries) with comfortable headroom. +const maxCertificateTemplateSubjectAlternativeNameLength = 4096 + +// subjectAlternativeNameAllowedKeys lists the SAN attribute KEYs the agent recognizes. The +// server validates KEY membership at create time so admins get fast feedback on typos. +var subjectAlternativeNameAllowedKeys = map[string]struct{}{ + "DNS": {}, + "EMAIL": {}, + "UPN": {}, + "IP": {}, + "URI": {}, +} + func validateCertificateTemplateFleetVariables(subjectName string) error { fleetVars := variables.Find(subjectName) if len(fleetVars) == 0 { @@ -32,14 +48,73 @@ func validateCertificateTemplateFleetVariables(subjectName string) error { return nil } -// replaceCertificateVariables replaces FLEET_VAR_* variables in the subject name with actual host values -func (svc *Service) replaceCertificateVariables(ctx context.Context, subjectName string, host *fleet.Host) (string, error) { - fleetVars := variables.Find(subjectName) +// validateCertificateTemplateSubjectAlternativeName performs lightweight format-only validation +// of the SAN string. Empty / whitespace-only input is permitted (means no SAN). For non-empty +// values it checks the length cap, that each non-empty comma-separated token contains '=' with +// non-empty content on both sides, that each KEY is in the allowlist (DNS, EMAIL, UPN, IP, +// URI), and that at least one valid token is present (rejects separator-only inputs like ","). +// The value (right of '=') is otherwise not validated; value content is parsed by the Android +// agent at delivery time, where any $FLEET_VAR_* references have already been expanded. +// +// certName is suffixed onto each error reason as "(certificate )" for GitOps multi-cert +// clarity; pass "" from single-cert callers like CreateCertificateTemplate where the failing +// cert is unambiguous. Returns a typed *fleet.InvalidArgumentError on failure (HTTP 422, +// errors[].name = "subject_alternative_name") or nil on success. +func validateCertificateTemplateSubjectAlternativeName(san, certName string) error { + const field = "subject_alternative_name" + mkErr := func(reason string) error { + if certName != "" { + reason = fmt.Sprintf("%s (certificate %s)", reason, certName) + } + return fleet.NewInvalidArgumentError(field, reason) + } + if strings.TrimSpace(san) == "" { + return nil + } + if len(san) > maxCertificateTemplateSubjectAlternativeNameLength { + return mkErr(fmt.Sprintf("is too long. Maximum is %d bytes", + maxCertificateTemplateSubjectAlternativeNameLength)) + } + tokensSeen := 0 + for raw := range strings.SplitSeq(san, ",") { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + tokensSeen++ + eqIdx := strings.Index(token, "=") + if eqIdx == -1 { + return mkErr(fmt.Sprintf("token %q is missing '='", token)) + } + if eqIdx == 0 { + return mkErr(fmt.Sprintf("token %q has an empty key", token)) + } + key := strings.ToUpper(strings.TrimSpace(token[:eqIdx])) + if _, ok := subjectAlternativeNameAllowedKeys[key]; !ok { + return mkErr(fmt.Sprintf("has unsupported key %q. Allowed keys are DNS, EMAIL, UPN, IP, URI", key)) + } + if strings.TrimSpace(token[eqIdx+1:]) == "" { + return mkErr(fmt.Sprintf("token %q has an empty value", token)) + } + } + if tokensSeen == 0 { + return mkErr("contains no entries") + } + return nil +} + +// replaceCertificateVariables replaces FLEET_VAR_* variables in the input string with actual +// host values. endUsersMemo is an optional cross-call cache for the host's end-user list — pass +// the same `*[]fleet.HostEndUser` (with `*memo == nil` initially) into successive calls for the +// same host to avoid re-fetching from the datastore. The IDP-username variable is the only one +// that triggers a DB round-trip; UUID and hardware serial come from the in-memory host struct. +func (svc *Service) replaceCertificateVariables(ctx context.Context, input string, host *fleet.Host, endUsersMemo *[]fleet.HostEndUser) (string, error) { + fleetVars := variables.Find(input) if len(fleetVars) == 0 { - return subjectName, nil + return input, nil } - result := subjectName + result := input for _, fleetVar := range fleetVars { switch fleetVar { case string(fleet.FleetVarHostUUID): @@ -53,9 +128,18 @@ func (svc *Service) replaceCertificateVariables(ctx context.Context, subjectName } result = fleet.FleetVarHostHardwareSerialRegexp.ReplaceAllString(result, host.HardwareSerial) case string(fleet.FleetVarHostEndUserIDPUsername): - users, err := fleet.GetEndUsers(ctx, svc.ds, host.ID) - if err != nil { - return "", ctxerr.Wrapf(ctx, err, "getting host end users for variable %s", fleetVar) + var users []fleet.HostEndUser + if endUsersMemo != nil && *endUsersMemo != nil { + users = *endUsersMemo + } else { + fetched, err := fleet.GetEndUsers(ctx, svc.ds, host.ID) + if err != nil { + return "", ctxerr.Wrapf(ctx, err, "getting host end users for variable %s", fleetVar) + } + users = fetched + if endUsersMemo != nil { + *endUsersMemo = users + } } if len(users) == 0 || users[0].IdpUserName == "" { return "", ctxerr.Errorf(ctx, "host %s does not have an IDP username for variable %s", host.UUID, fleetVar) diff --git a/server/service/certificate_templates_test.go b/server/service/certificate_templates_test.go index 781f59371c4..a9598d5164f 100644 --- a/server/service/certificate_templates_test.go +++ b/server/service/certificate_templates_test.go @@ -16,7 +16,8 @@ import ( func TestCreateCertificateTemplate(t *testing.T) { ds := new(mock.Store) - svc, ctx := newTestService(t, ds, nil, nil) + // Certificate templates are Premium-gated (CAs are Premium, and templates require a CA). + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) @@ -65,19 +66,19 @@ func TestCreateCertificateTemplate(t *testing.T) { return &fleet.AppConfig{}, nil } t.Run("Invalid CA type", func(t *testing.T) { - _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(InvalidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(InvalidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.Error(t, err) // Check that the error is about invalid CA type require.Contains(t, err.Error(), "Currently, only the custom_scep_proxy certificate authority is supported") }) t.Run("Valid CA type", func(t *testing.T) { - _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.NoError(t, err) }) t.Run("Missing CA", func(t *testing.T) { - _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, 999, "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, 999, "CN=$FLEET_VAR_HOST_UUID", "") require.Error(t, err) // Check that the error is about invalid CA type require.Contains(t, err.Error(), "not found") @@ -86,7 +87,7 @@ func TestCreateCertificateTemplate(t *testing.T) { t.Run("Empty or whitespace-only name", func(t *testing.T) { whitespaceNames := []string{"", " ", " ", "\t", "\n", " \t\n "} for _, name := range whitespaceNames { - _, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.Error(t, err) require.Contains(t, err.Error(), "Certificate template name is required") } @@ -94,7 +95,7 @@ func TestCreateCertificateTemplate(t *testing.T) { t.Run("Name too long", func(t *testing.T) { longName := strings.Repeat("a", 256) - _, err := svc.CreateCertificateTemplate(ctx, longName, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, longName, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.Error(t, err) require.Contains(t, err.Error(), "Certificate template name is too long") }) @@ -131,7 +132,7 @@ func TestCreateCertificateTemplate(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := svc.CreateCertificateTemplate(ctx, tc.name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, tc.name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.Error(t, err) require.Contains(t, err.Error(), "Invalid certificate template name") }) @@ -156,7 +157,7 @@ func TestCreateCertificateTemplate(t *testing.T) { } for _, name := range validNames { t.Run(name, func(t *testing.T) { - _, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID") + _, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "") require.NoError(t, err) }) } @@ -165,16 +166,165 @@ func TestCreateCertificateTemplate(t *testing.T) { t.Run("Empty or whitespace-only subject name", func(t *testing.T) { whitespaceSubjectNames := []string{"", " ", " \t\n "} for _, subjectName := range whitespaceSubjectNames { - _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), subjectName) + _, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), subjectName, "") require.Error(t, err) require.Contains(t, err.Error(), "Certificate template subject name is required") } }) } +func TestCreateCertificateTemplateSubjectAlternativeName(t *testing.T) { + const ValidCATypeID = uint(2) + const TeamID = 1 + + makePremiumService := func(t *testing.T) (fleet.Service, context.Context, *mock.Store) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + + ds.GetCertificateAuthorityByIDFunc = func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) { + return &fleet.CertificateAuthority{ID: id, Type: string(fleet.CATypeCustomSCEPProxy)}, nil + } + ds.CreateCertificateTemplateFunc = func(ctx context.Context, certificateTemplate *fleet.CertificateTemplate) (*fleet.CertificateTemplateResponse, error) { + return &fleet.CertificateTemplateResponse{ + CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{ + ID: 1, + Name: certificateTemplate.Name, + SubjectName: certificateTemplate.SubjectName, + SubjectAlternativeName: certificateTemplate.SubjectAlternativeName, + }, + TeamID: certificateTemplate.TeamID, + }, nil + } + ds.CreatePendingCertificateTemplatesForExistingHostsFunc = func(ctx context.Context, certificateTemplateID uint, teamID uint) (int64, error) { + return 0, nil + } + ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{ID: tid, Name: "Yellow jackets"}, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + return svc, ctx, ds + } + + t.Run("Premium tenant with valid SAN succeeds and round-trips the value", func(t *testing.T) { + svc, ctx, ds := makePremiumService(t) + + san := "DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME" + resp, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", san) + require.NoError(t, err) + require.Equal(t, san, resp.SubjectAlternativeName) + require.True(t, ds.CreateCertificateTemplateFuncInvoked) + }) + + t.Run("Non-Premium tenant cannot create any certificate template (gate is in CreateCertificateTemplate, before validation)", func(t *testing.T) { + // Certificate templates require a CA, and CAs are Premium-only, so the whole feature is + // gated by a Premium check at the top of Service.CreateCertificateTemplate. SAN-bearing + // payloads are not the only ones rejected. + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + + // With SAN. + _, err := svc.CreateCertificateTemplate(ctx, "wifi-with-san", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "DNS=example.com") + require.ErrorIs(t, err, fleet.ErrMissingLicense) + + // Without SAN also rejected. + _, err = svc.CreateCertificateTemplate(ctx, "wifi-no-san", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "") + require.ErrorIs(t, err, fleet.ErrMissingLicense) + }) + + t.Run("Format failures return InvalidArgumentError scoped to the SAN field", func(t *testing.T) { + svc, ctx, _ := makePremiumService(t) + + cases := []struct { + name string + san string + fragment string + }{ + {"missing equals", "DNS=ok, OOPS", "missing '='"}, + {"unknown key", "FOO=bar", "unsupported key"}, + {"rfc822 not synonym", "RFC822=user@x", "unsupported key"}, + {"too long", strings.Repeat("DNS=a,", 1024), "too long"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", tc.san) + require.Error(t, err) + var iae *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae) + require.True(t, iae.HasErrors()) + details := iae.Invalid() + require.Len(t, details, 1) + require.Equal(t, "subject_alternative_name", details[0]["name"]) + require.Contains(t, details[0]["reason"], tc.fragment) + }) + } + }) + + t.Run("Unsupported variable in SAN is rejected", func(t *testing.T) { + svc, ctx, _ := makePremiumService(t) + + _, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "EMAIL=$FLEET_VAR_HOST_PLATFORM") + require.Error(t, err) + require.Contains(t, err.Error(), "FLEET_VAR_HOST_PLATFORM") + }) +} + +func TestValidateCertificateTemplateSubjectAlternativeName(t *testing.T) { + cases := []struct { + name string + san string + expectError bool + errContains string + }{ + {"empty allowed", "", false, ""}, + {"whitespace allowed", " \t\n ", false, ""}, + {"single DNS", "DNS=example.com", false, ""}, + {"single EMAIL", "EMAIL=user@example.com", false, ""}, + {"single UPN", "UPN=user@corp.example.com", false, ""}, + {"single IP", "IP=10.0.0.1", false, ""}, + {"single URI", "URI=spiffe://example.com/x", false, ""}, + {"all five mixed", "DNS=a, EMAIL=b@x, UPN=c@d, IP=10.0.0.1, URI=spiffe://x", false, ""}, + {"case insensitive keys", "dns=a, email=b@x, upn=c@d, ip=10.0.0.1, uri=spiffe://x", false, ""}, + {"repeated keys", "DNS=a, DNS=b, EMAIL=c@x, EMAIL=d@y", false, ""}, + {"trailing comma is fine", "DNS=a,", false, ""}, + {"missing equals", "DNS=a, OOPS", true, "missing '='"}, + {"unknown key FOO", "FOO=bar", true, "unsupported key"}, + {"RFC822 is not a synonym", "RFC822=user@x", true, "unsupported key"}, + {"length cap", strings.Repeat("DNS=a,", 1024), true, "too long"}, + {"empty key with equals only", "=value", true, "empty key"}, + {"empty value DNS=", "DNS=", true, "empty value"}, + {"empty value EMAIL= mixed", "DNS=ok.example.com, EMAIL=", true, "empty value"}, + {"separator only", ",", true, "no entries"}, + {"separator only with whitespace", " , ", true, "no entries"}, + {"only commas", ",,,", true, "no entries"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCertificateTemplateSubjectAlternativeName(tc.san, "") + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + // Validator must return a typed *fleet.InvalidArgumentError scoped to the SAN field (HTTP 422). + var iae *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae) + require.True(t, iae.HasErrors()) + details := iae.Invalid() + require.Len(t, details, 1) + require.Equal(t, "subject_alternative_name", details[0]["name"]) + } else { + require.NoError(t, err) + } + }) + } +} + func TestApplyCertificateTemplateSpecs(t *testing.T) { ds := new(mock.Store) - svc, ctx := newTestService(t, ds, nil, nil) + // Certificate templates are Premium-gated. + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) diff --git a/server/service/certificates.go b/server/service/certificates.go index bba6d7ee1a2..177c1c23b48 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -53,12 +53,14 @@ type createCertificateTemplateRequest struct { TeamID uint `json:"team_id" renameto:"fleet_id"` // If not provided, intentionally defaults to 0 aka "No team" CertificateAuthorityId uint `json:"certificate_authority_id"` SubjectName string `json:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty"` } type createCertificateTemplateResponse struct { ID uint `json:"id"` Name string `json:"name"` CertificateAuthorityId uint `json:"certificate_authority_id"` SubjectName string `json:"subject_name"` + SubjectAlternativeName string `json:"subject_alternative_name,omitempty"` Err error `json:"error,omitempty"` } @@ -66,7 +68,7 @@ func (r createCertificateTemplateResponse) Error() error { return r.Err } func createCertificateTemplateEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*createCertificateTemplateRequest) - certificate, err := svc.CreateCertificateTemplate(ctx, req.Name, req.TeamID, req.CertificateAuthorityId, req.SubjectName) + certificate, err := svc.CreateCertificateTemplate(ctx, req.Name, req.TeamID, req.CertificateAuthorityId, req.SubjectName, req.SubjectAlternativeName) if err != nil { return createCertificateTemplateResponse{Err: err}, nil } @@ -75,14 +77,25 @@ func createCertificateTemplateEndpoint(ctx context.Context, request interface{}, Name: certificate.Name, CertificateAuthorityId: certificate.CertificateAuthorityId, SubjectName: certificate.SubjectName, + SubjectAlternativeName: certificate.SubjectAlternativeName, }, nil } -func (svc *Service) CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string) (*fleet.CertificateTemplateResponse, error) { +func (svc *Service) CreateCertificateTemplate(ctx context.Context, name string, teamID uint, certificateAuthorityID uint, subjectName string, subjectAlternativeName string) (*fleet.CertificateTemplateResponse, error) { if err := svc.authz.Authorize(ctx, &fleet.CertificateTemplate{TeamID: teamID}, fleet.ActionWrite); err != nil { return nil, err } + // Certificate templates require a custom SCEP CA, and CAs are Premium-only (see + // server/service/certificate_authorities.go core stubs). Reject any create on Free up front. + lic, err := svc.License(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting license") + } + if !lic.IsPremium() { + return nil, fleet.ErrMissingLicense + } + // Validate certificate template name if err := validateCertificateTemplateName(name); err != nil { return nil, err @@ -96,6 +109,17 @@ func (svc *Service) CreateCertificateTemplate(ctx context.Context, name string, return nil, &fleet.BadRequestError{Message: err.Error()} } + // Validate the optional SAN: format (token shape, KEY allow-list, length cap) and any + // $FLEET_VAR_* references against the same allow-list as subject_name. + if strings.TrimSpace(subjectAlternativeName) != "" { + if err := validateCertificateTemplateSubjectAlternativeName(subjectAlternativeName, ""); err != nil { + return nil, err + } + if err := validateCertificateTemplateFleetVariables(subjectAlternativeName); err != nil { + return nil, fleet.NewInvalidArgumentError("subject_alternative_name", err.Error()) + } + } + // Get the CA to validate its existence and type. ca, err := svc.ds.GetCertificateAuthorityByID(ctx, certificateAuthorityID, false) if err != nil { @@ -111,6 +135,7 @@ func (svc *Service) CreateCertificateTemplate(ctx context.Context, name string, TeamID: teamID, CertificateAuthorityID: certificateAuthorityID, SubjectName: subjectName, + SubjectAlternativeName: subjectAlternativeName, } savedTemplate, err := svc.ds.CreateCertificateTemplate(ctx, certTemplate) @@ -240,7 +265,10 @@ func (svc *Service) GetDeviceCertificateTemplate(ctx context.Context, id uint) ( return nil, fleet.NewPermissionError("host does not have access to this certificate template") } - subjectName, err := svc.replaceCertificateVariables(ctx, certificate.SubjectName, host) + // Memo for the host's end-user list, shared between subject_name and subject_alternative_name + // expansion so we don't double-fetch when both reference $FLEET_VAR_HOST_END_USER_IDP_USERNAME. + var endUsersMemo []fleet.HostEndUser + subjectName, err := svc.replaceCertificateVariables(ctx, certificate.SubjectName, host, &endUsersMemo) if err != nil { // If the certificate variables cannot be replaced, mark the certificate as failed. errorMsg := fmt.Sprintf("Could not replace certificate variables: %s", err.Error()) @@ -254,10 +282,35 @@ func (svc *Service) GetDeviceCertificateTemplate(ctx context.Context, id uint) ( return nil, err } certificate.Status = fleet.CertificateTemplateFailed + // Active challenges from the prior delivered status must not ride along on a failed response. + certificate.SCEPChallenge = nil + certificate.FleetChallenge = nil return certificate, nil } certificate.SubjectName = subjectName + // Expand variables in SAN with the same error semantics as subject_name. + if certificate.SubjectAlternativeName != "" { + san, err := svc.replaceCertificateVariables(ctx, certificate.SubjectAlternativeName, host, &endUsersMemo) + if err != nil { + errorMsg := fmt.Sprintf("Could not replace certificate variables in subject_alternative_name: %s", err.Error()) + if err := svc.ds.UpsertCertificateStatus(ctx, &fleet.CertificateStatusUpdate{ + HostUUID: host.UUID, + CertificateTemplateID: certificate.ID, + Status: fleet.MDMDeliveryFailed, + Detail: &errorMsg, + OperationType: fleet.MDMOperationTypeInstall, + }); err != nil { + return nil, err + } + certificate.Status = fleet.CertificateTemplateFailed + certificate.SCEPChallenge = nil + certificate.FleetChallenge = nil + return certificate, nil + } + certificate.SubjectAlternativeName = san + } + // On-demand challenge creation for delivered status. // If FleetChallenge is nil or empty, create one now (the challenge TTL starts from this moment). if certificate.Status == fleet.CertificateTemplateDelivered { @@ -431,6 +484,17 @@ func (svc *Service) ApplyCertificateTemplateSpecs(ctx context.Context, specs []* return err } + // Certificate templates require a custom SCEP CA, and CAs are Premium-only. + if len(specs) > 0 { + lic, err := svc.License(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting license") + } + if !lic.IsPremium() { + return fleet.ErrMissingLicense + } + } + // Get all of the CAs. cas, err := svc.ds.ListCertificateAuthorities(ctx) if err != nil { @@ -467,12 +531,24 @@ func (svc *Service) ApplyCertificateTemplateSpecs(ctx context.Context, specs []* return &fleet.BadRequestError{Message: fmt.Sprintf("%s (certificate %s)", err.Error(), spec.Name)} } + // Validate the optional SAN for format and variables. + if strings.TrimSpace(spec.SubjectAlternativeName) != "" { + if err := validateCertificateTemplateSubjectAlternativeName(spec.SubjectAlternativeName, spec.Name); err != nil { + return err + } + if err := validateCertificateTemplateFleetVariables(spec.SubjectAlternativeName); err != nil { + return fleet.NewInvalidArgumentError("subject_alternative_name", + fmt.Sprintf("%s (certificate %s)", err.Error(), spec.Name)) + } + } + teamID := teamNameToID[spec.Team] cert := &fleet.CertificateTemplate{ Name: spec.Name, CertificateAuthorityID: spec.CertificateAuthorityId, SubjectName: spec.SubjectName, + SubjectAlternativeName: spec.SubjectAlternativeName, TeamID: teamID, } diff --git a/server/service/client.go b/server/service/client.go index fde1f2fa6e6..495f0f00d45 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2519,7 +2519,7 @@ func (c *Client) DoGitOps( // prevents a race where the cron fires after profiles are uploaded (by // ApplyGroup above) but before cert templates exist, which would cause ONC // profiles to be sent without waiting for the cert. - err = c.doGitOpsAndroidCertificates(incoming, logFn, dryRun) + err = c.doGitOpsAndroidCertificates(incoming, appConfig, logFn, dryRun) if err != nil { var gitOpsErr *gitOpsValidationError if errors.As(err, &gitOpsErr) { @@ -3304,7 +3304,7 @@ func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, return nil } -func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { +func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, appConfig *fleet.EnrichedAppConfig, logFn func(format string, args ...any), dryRun bool) error { certificates := make([]fleet.CertificateTemplateSpec, 0) // Extract Android certificates from config if there are any. @@ -3341,6 +3341,16 @@ func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, logFn func(for return nil } + // Certificate templates require a custom SCEP CA, and CAs are Premium-only. If the YAML + // declares any certificates against a Free server, bail with a friendly error before doing + // anything destructive on the team. Mirrors the certificate_authorities check at line ~597. + // appConfig is passed in from DoGitOps, which has already fetched it once for upstream + // Premium checks; reuse rather than refetch. + if numCerts > 0 && (appConfig == nil || appConfig.License == nil || !appConfig.License.IsPremium()) { + return newGitOpsValidationError( + "Android certificate templates require a custom SCEP CA, which is available in Fleet Premium only.") + } + if dryRun { logFn("[+] would have attempted to apply %s\n", numberWithPluralization(numCerts, "Android certificate", "Android certificates")) } else { @@ -3373,6 +3383,31 @@ func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, logFn func(for ) } + // Normalize whitespace-only SAN to "" so the value we send matches the server's + // read-back (the server stores whitespace-only as NULL and COALESCEs reads to ""). Without + // this, hand-authored YAML like `subject_alternative_name: " "` would mismatch on every + // apply and trigger an infinite delete+recreate cycle. + san := certificates[i].SubjectAlternativeName + if strings.TrimSpace(san) == "" { + san = "" + } + + // Validate the optional SAN at GitOps time so admins get fast feedback before the apply + // reaches the server. Server re-validates as the source of truth. + if san != "" { + if err := validateCertificateTemplateSubjectAlternativeName(san, certificates[i].Name); err != nil { + return newGitOpsValidationError( + fmt.Sprintf(`Invalid subject_alternative_name in certificate %q: %s`, certificates[i].Name, err.Error()), + ) + } + if err := validateCertificateTemplateFleetVariables(san); err != nil { + return newGitOpsValidationError( + fmt.Sprintf(`Invalid Fleet variable in subject_alternative_name of certificate %q: %s`, + certificates[i].Name, err.Error()), + ) + } + } + ca, ok := casByName[certificates[i].CertificateAuthorityName] if !ok { return fmt.Errorf("certificate authority %q not found for certificate %q", @@ -3388,6 +3423,7 @@ func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, logFn func(for Team: teamName, CertificateAuthorityId: ca.ID, SubjectName: certificates[i].SubjectName, + SubjectAlternativeName: san, } if _, ok := certsToBeAdded[certificates[i].Name]; ok { return newGitOpsValidationError( @@ -3407,8 +3443,10 @@ func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, logFn func(for newCert, exists := certsToBeAdded[cert.Name] if !exists { certificatesToDelete = append(certificatesToDelete, cert.ID) - } else if cert.SubjectName != newCert.SubjectName || cert.CertificateAuthorityId != newCert.CertificateAuthorityId { - // SubjectName or CA changed, mark for deletion (will be recreated) + } else if cert.SubjectName != newCert.SubjectName || + cert.SubjectAlternativeName != newCert.SubjectAlternativeName || + cert.CertificateAuthorityId != newCert.CertificateAuthorityId { + // Subject, SAN, or CA changed; mark for deletion (will be recreated). certificatesToDelete = append(certificatesToDelete, cert.ID) } } diff --git a/server/service/integration_android_certificate_templates_test.go b/server/service/integration_android_certificate_templates_test.go index 93a75aaa0ee..1a8f2704832 100644 --- a/server/service/integration_android_certificate_templates_test.go +++ b/server/service/integration_android_certificate_templates_test.go @@ -573,6 +573,88 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateNoTeamWithIDPVariable() s.verifyCertificateStatusWithSubject(t, host, orbitNodeKey, certificateTemplateID, certTemplateName, caID, fleet.CertificateTemplateFailed, "", subjectName) } +// TestCertificateTemplateWithSANIDPVariable tests that subject_alternative_name supports the +// same $FLEET_VAR_HOST_* expansion as subject_name, end to end: +// 1. Premium tenant creates a cert template with subject_alternative_name containing +// $FLEET_VAR_HOST_END_USER_IDP_USERNAME. +// 2. Android host with no team is enrolled and associated with an IdP account. +// 3. The fleetd certificate API returns the rendered SAN with the IdP username substituted. +func (s *integrationMDMTestSuite) TestCertificateTemplateWithSANIDPVariable() { + t := s.T() + ctx := t.Context() + enterpriseID := s.enableAndroidMDM(t) + + caID, _ := s.createTestCertificateAuthority(t, ctx) + + // Insert an IdP account that the Android host will be associated with so the + // $FLEET_VAR_HOST_END_USER_IDP_USERNAME variable resolves at delivery time. + idpUsername := fmt.Sprintf("san.idp.%s@example.com", strings.ReplaceAll(uuid.NewString(), "-", "")) + require.NoError(t, s.ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ + Username: idpUsername, + Fullname: "SAN Test User", + Email: idpUsername, + })) + insertedIdP, err := s.ds.GetMDMIdPAccountByEmail(ctx, idpUsername) + require.NoError(t, err) + require.NotNil(t, insertedIdP) + + // Create the cert template with both subject_name and subject_alternative_name using the + // IdP-username variable. Both should expand at delivery time. + certTemplateName := strings.ReplaceAll(t.Name(), "/", "-") + "-CertTemplate" + subjectName := "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME" + subjectAlternativeName := "DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME" + + var createResp createCertificateTemplateResponse + s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{ + Name: certTemplateName, + TeamID: 0, + CertificateAuthorityId: caID, + SubjectName: subjectName, + SubjectAlternativeName: subjectAlternativeName, + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.ID) + require.Equal(t, subjectAlternativeName, createResp.SubjectAlternativeName) + certificateTemplateID := createResp.ID + + // Enroll an Android host with no team and link it to the IdP account. + host, orbitNodeKey := s.createEnrolledAndroidHost(t, ctx, enterpriseID, nil, "san") + require.NoError(t, s.ds.AssociateHostMDMIdPAccount(ctx, host.UUID, insertedIdP.UUID)) + + // Create pending certificate templates for the host (simulating what the pubsub handler does + // during enrollment). + _, err = s.ds.CreatePendingCertificateTemplatesForNewHost(ctx, host.UUID, 0) + require.NoError(t, err) + + // AMAPI mock succeeds. + s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(_ context.Context, _ string, _ []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) { + return &androidmanagement.Policy{}, nil + } + + // Run the Android setup-experience worker so the template moves to delivered. + enterpriseName := "enterprises/" + enterpriseID + require.NoError(t, worker.QueueRunAndroidSetupExperience(ctx, s.ds, slog.New(slog.DiscardHandler), host.UUID, nil, enterpriseName)) + s.runWorker() + + // Fetch the certificate via the fleetd API. Both SN and SAN should have the IdP username + // substituted. + resp := s.DoRawWithHeaders("GET", + fmt.Sprintf("/api/fleetd/certificates/%d", certificateTemplateID), + nil, + http.StatusOK, + map[string]string{"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey)}, + ) + var getCertResp getDeviceCertificateTemplateResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) + _ = resp.Body.Close() + + require.NotNil(t, getCertResp.Certificate) + require.Equal(t, fleet.CertificateTemplateDelivered, getCertResp.Certificate.Status) + require.Equal(t, fmt.Sprintf("CN=%s", idpUsername), getCertResp.Certificate.SubjectName) + require.Equal(t, + fmt.Sprintf("DNS=wifi.example.com, UPN=%s, EMAIL=%s", idpUsername, idpUsername), + getCertResp.Certificate.SubjectAlternativeName) +} + // TestCertificateTemplateUnenrollReenroll tests: // 1. Host with existing certificate templates is unenrolled // 2. A new certificate template is added while host is unenrolled (should NOT be marked for this host) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 7c8942668d3..6a5071ea4cf 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8953,405 +8953,6 @@ func (s *integrationTestSuite) TestQuerySpecs() { assert.Equal(t, uint(3), delBatchResp.Deleted) } -func (s *integrationTestSuite) TestCertificatesSpecs() { - t := s.T() - ctx := context.Background() - - // create team - team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Test Team"}) - require.NoError(t, err) - - // Create a test certificate authority - ca, err := s.ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{ - Type: string(fleet.CATypeCustomSCEPProxy), - Name: ptr.String("Test SCEP CA"), - URL: ptr.String("http://localhost:8080/scep"), - Challenge: ptr.String("test-challenge"), - }) - require.NoError(t, err) - - // invalid Fleet variable in subject name - var applyResp applyCertificateTemplateSpecsResponse - s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ - Specs: []*fleet.CertificateRequestSpec{ - { - Name: "Invalid Template", - Team: team.Name, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_NOT_VALID/OU=$FLEET_VAR_HOST_UUID", - }, - }, - }, http.StatusBadRequest, &applyResp) - - // test with non-existent team name - s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ - Specs: []*fleet.CertificateRequestSpec{ - { - Name: "Invalid Team Template", - Team: "NonExistentTeam", - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_UUID", - }, - }, - }, http.StatusNotFound, &applyResp) - - activitiesBeforeInsert := s.listActivities() - - // valid templates - test team name (not team ID) - s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ - Specs: []*fleet.CertificateRequestSpec{ - { - Name: "Template 1", - Team: team.Name, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", - }, - { - Name: "Template 2", - Team: team.Name, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", - }, - }, - }, http.StatusOK, &applyResp) - - // Only one activity per team - activitiesAfterInsert := s.listActivities() - require.Len(t, activitiesAfterInsert, len(activitiesBeforeInsert)+1, "expected exactly one new activity for the team") - s.lastActivityMatches( - fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), - fmt.Sprintf(`{"fleet_id": %d, "fleet_name": %q, "team_id": %d, "team_name": %q}`, team.ID, team.Name, team.ID, team.Name), - 0, - ) - - // list specs - var listCertifcatesResp listCertificateTemplatesResponse - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) - require.Len(t, listCertifcatesResp.Certificates, 2) - assert.ElementsMatch(t, []string{"Template 1", "Template 2"}, []string{listCertifcatesResp.Certificates[0].Name, listCertifcatesResp.Certificates[1].Name}) - - lastActivityID := s.lastActivityMatches("", "", 0) - - s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ - Specs: []*fleet.CertificateRequestSpec{ - { - Name: "Template 1", - Team: team.Name, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", - }, - { - Name: "Template 2", - Team: team.Name, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", - }, - }, - }, http.StatusOK, &applyResp) - - // No new activities created - currentActivityID := s.lastActivityMatches("", "", 0) - assert.Equal(t, lastActivityID, currentActivityID, "no new activity should be created when re-applying same certificates") - - // Create a host to get certificate get by id endpoint - host, err := s.ds.NewHost(ctx, &fleet.Host{ - DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), - PolicyUpdatedAt: time.Now(), - SeenTime: time.Now(), - NodeKey: ptr.String("test-cert-node-key"), - UUID: "test-uuid-12345", - Hostname: "test-cert-host.local", - HardwareSerial: "TEST-SERIAL-67890", - TeamID: &team.ID, - }) - require.NoError(t, err) - - orbitNodeKey := uuid.New().String() - host.OrbitNodeKey = &orbitNodeKey - require.NoError(t, s.ds.UpdateHost(ctx, host)) - - savedCertificateTemplates, _, err := s.ds.GetCertificateTemplatesByTeamID(ctx, team.ID, fleet.ListOptions{Page: 0, PerPage: 10}) - require.NoError(t, err) - certID := savedCertificateTemplates[0].ID - - // Create a host_certificate_templates record for this host (simulating what happens during Android enrollment) - // The endpoint /api/fleetd/certificates/{id} requires a host_certificate_templates record to exist - err = s.ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ - { - HostUUID: host.UUID, - CertificateTemplateID: certID, - Status: fleet.CertificateTemplateDelivered, - OperationType: fleet.MDMOperationTypeInstall, - }, - }) - require.NoError(t, err) - - var getCertResp getDeviceCertificateTemplateResponse - - resp := s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusOK, map[string]string{ - "Authorization": fmt.Sprintf("Node key %s", orbitNodeKey), - }) - require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) - require.NoError(t, resp.Body.Close()) - require.Equal(t, getCertResp.Certificate.Status, fleet.CertificateTemplateFailed) // failed because no IDP user to replace variables - - // Add an IDP user for the host - err = s.ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ - { - HostID: host.ID, - Email: "test.user@example.com", - Source: fleet.DeviceMappingMDMIdpAccounts, - }, - }, fleet.DeviceMappingMDMIdpAccounts) - require.NoError(t, err) - - // Get certificate without node_key - resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusUnauthorized, nil) - require.NoError(t, resp.Body.Close()) - - // Get certificate with node_key (should return replaced variables) - resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusOK, map[string]string{ - "Authorization": fmt.Sprintf("Node key %s", orbitNodeKey), - }) - require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) - require.NoError(t, resp.Body.Close()) - require.NotNil(t, getCertResp.Certificate) - - assert.Contains(t, getCertResp.Certificate.SubjectName, "test.user@example.com") - assert.Contains(t, getCertResp.Certificate.SubjectName, "test-uuid-12345") - assert.Contains(t, getCertResp.Certificate.SubjectName, "TEST-SERIAL-67890") - - // Get certificate without host_uuid (should return subject with variables) - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates/%d", certID), nil, http.StatusOK, &getCertResp) - require.NotNil(t, getCertResp.Certificate) - - assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_END_USER_IDP_USERNAME") - assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_UUID") - assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_HARDWARE_SERIAL") - - // batch delete certificate templates - var delBatchResp deleteCertificateTemplateSpecsResponse - s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]interface{}{ - "ids": []uint{listCertifcatesResp.Certificates[0].ID, listCertifcatesResp.Certificates[1].ID}, - "team_id": team.ID, - }, http.StatusOK, &delBatchResp) - - // Verify activity was created for deleting certificates - s.lastActivityMatches( - fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), - fmt.Sprintf(`{"fleet_id": %d, "fleet_name": %q, "team_id": %d, "team_name": %q}`, team.ID, team.Name, team.ID, team.Name), - 0, - ) - - // list specs - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) - require.Len(t, listCertifcatesResp.Certificates, 0) - - activitiesBeforeNoTeam := s.listActivities() - - // certificate templates for "No team" - s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ - Specs: []*fleet.CertificateRequestSpec{ - { - Name: "No Team Template 1", - Team: "No team", - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", - }, - { - Name: "No Team Template 2", - Team: "", - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL", - }, - { - Name: "No Team Template 3", - // No Team field, should default to empty string - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_UUID", - }, - }, - }, http.StatusOK, &applyResp) - - // Only one activity was created for "No team" - activitiesAfterNoTeam := s.listActivities() - require.Len(t, activitiesAfterNoTeam, len(activitiesBeforeNoTeam)+1, "expected exactly one new activity for no team") - s.lastActivityMatches( - fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), - `{"fleet_id": null, "fleet_name": null, "team_id": null, "team_name": null}`, - 0, - ) - - // list specs for "no team" (team_id 0) - var noTeamCertificatesResp listCertificateTemplatesResponse - s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) - require.Len(t, noTeamCertificatesResp.Certificates, 3) - certNames := []string{noTeamCertificatesResp.Certificates[0].Name, noTeamCertificatesResp.Certificates[1].Name, noTeamCertificatesResp.Certificates[2].Name} - assert.ElementsMatch(t, []string{"No Team Template 1", "No Team Template 2", "No Team Template 3"}, certNames) - - // Create a host - noTeamHost, err := s.ds.NewHost(ctx, &fleet.Host{ - DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), - PolicyUpdatedAt: time.Now(), - SeenTime: time.Now(), - NodeKey: ptr.String("test-cert-no-team-node-key"), - UUID: "test-no-team-uuid-12345", - Hostname: "test-cert-no-team-host.local", - HardwareSerial: "TEST-NO-TEAM-SERIAL", - TeamID: nil, // No team - }) - require.NoError(t, err) - - noTeamOrbitNodeKey := uuid.New().String() - noTeamHost.OrbitNodeKey = &noTeamOrbitNodeKey - require.NoError(t, s.ds.UpdateHost(ctx, noTeamHost)) - - // Add an IDP user for host - err = s.ds.ReplaceHostDeviceMapping(ctx, noTeamHost.ID, []*fleet.HostDeviceMapping{ - { - HostID: noTeamHost.ID, - Email: "no.team.user@example.com", - Source: fleet.DeviceMappingMDMIdpAccounts, - }, - }, fleet.DeviceMappingMDMIdpAccounts) - require.NoError(t, err) - - savedNoTeamCertTemplates, _, err := s.ds.GetCertificateTemplatesByTeamID(ctx, 0, fleet.ListOptions{Page: 0, PerPage: 10}) - require.NoError(t, err) - require.Len(t, savedNoTeamCertTemplates, 3) - noTeamCertID := savedNoTeamCertTemplates[0].ID - - // Create a host_certificate_templates record for this no-team host - err = s.ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ - { - HostUUID: noTeamHost.UUID, - CertificateTemplateID: noTeamCertID, - Status: fleet.CertificateTemplateDelivered, - OperationType: fleet.MDMOperationTypeInstall, - }, - }) - require.NoError(t, err) - - // Get certificate with orbit node_key (should return replaced variables) - var getNoTeamCertResp getDeviceCertificateTemplateResponse - resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", noTeamCertID), nil, http.StatusOK, map[string]string{ - "Authorization": fmt.Sprintf("Node key %s", noTeamOrbitNodeKey), - }) - require.NoError(t, json.NewDecoder(resp.Body).Decode(&getNoTeamCertResp)) - require.NoError(t, resp.Body.Close()) - require.NotNil(t, getNoTeamCertResp.Certificate) - assert.Contains(t, getNoTeamCertResp.Certificate.SubjectName, "no.team.user@example.com") - assert.Contains(t, getNoTeamCertResp.Certificate.SubjectName, "test-no-team-uuid-12345") - - var profilesSummaryResp getMDMProfilesSummaryResponse - s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", nil, http.StatusOK, &profilesSummaryResp) - require.NotNil(t, profilesSummaryResp) - require.Equal(t, uint(0), profilesSummaryResp.Verified) - require.Equal(t, uint(0), profilesSummaryResp.Verifying) - require.Equal(t, uint(0), profilesSummaryResp.Failed) - require.Equal(t, uint(0), profilesSummaryResp.Pending) - - // creating a certificate - var createCertResp createCertificateTemplateResponse - s.DoJSON("POST", "/api/latest/fleet/certificates", map[string]interface{}{ - "name": "POST No Team Cert", - "certificate_authority_id": ca.ID, - "subject_name": "CN=$FLEET_VAR_HOST_UUID", - // team_id intentionally omitted - should default to 0 - }, http.StatusOK, &createCertResp) - require.NotZero(t, createCertResp.ID) - require.Equal(t, "POST No Team Cert", createCertResp.Name) - - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &getCertResp) - require.NotNil(t, getCertResp.Certificate) - - // Delete is authorized properly - observerEmail := "observer-cert-test@fleetdm.com" - observerPwd := test.GoodPassword - observerUser := &fleet.User{ - Name: "Observer User", - Email: observerEmail, - GlobalRole: ptr.String(fleet.RoleObserver), - } - require.NoError(t, observerUser.SetPassword(observerPwd, 10, 10)) - _, err = s.ds.NewUser(ctx, observerUser) - require.NoError(t, err) - - // Switch to observer user - s.token = s.getCachedUserToken(observerEmail, observerPwd) - // just in case test fails, restore to admin - defer func() { s.token = s.getTestAdminToken() }() - - // Delete with observer - resp = s.Do("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ - "ids": []uint{savedNoTeamCertTemplates[0].ID}, - "team_id": uint(0), // "No team" - }, http.StatusForbidden) - resp.Body.Close() - - // Switch back to admin - s.token = s.getTestAdminToken() - - // Verify the certificate still exists (wasn't deleted by observer) - s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) - found := false - for _, cert := range noTeamCertificatesResp.Certificates { - if cert.ID == savedNoTeamCertTemplates[0].ID { - found = true - break - } - } - require.True(t, found, "Certificate should not be deleted by observer user") - - // Cannot delete certificates from a different team - // Create team 2 - team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Test Team 2"}) - require.NoError(t, err) - team2ID := team2.ID - // Create a certificate in team2 - var team2CertResp createCertificateTemplateResponse - s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{ - Name: "Team 2 Cert", - TeamID: team2ID, - CertificateAuthorityId: ca.ID, - SubjectName: "CN=$FLEET_VAR_HOST_UUID", - }, http.StatusOK, &team2CertResp) - var forbiddenDelResp deleteCertificateTemplateSpecsResponse - // Delete with team 1 id and certificate from team 2 - s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ - "ids": []uint{team2CertResp.ID}, - "team_id": team.ID, - }, http.StatusForbidden, &forbiddenDelResp) - var listTeam2Resp listCertificateTemplatesResponse - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team2ID), - nil, http.StatusOK, &listTeam2Resp) - require.Len(t, listTeam2Resp.Certificates, 1) - require.Equal(t, "Team 2 Cert", listTeam2Resp.Certificates[0].Name) - - var delResp deleteCertificateTemplateResponse - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &delResp) - - var delBatchResp2 deleteCertificateTemplateSpecsResponse - s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]interface{}{ - "ids": []uint{noTeamCertificatesResp.Certificates[0].ID}, - // team_id intentionally omitted - should default to 0 - }, http.StatusOK, &delBatchResp2) - - s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) - require.Len(t, noTeamCertificatesResp.Certificates, 2) - require.Equal(t, noTeamCertificatesResp.Certificates[0].ID, noTeamCertificatesResp.Certificates[0].ID) - - s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]interface{}{ - "ids": []uint{noTeamCertificatesResp.Certificates[0].ID, noTeamCertificatesResp.Certificates[1].ID}, - "team_id": uint(0), - }, http.StatusOK, &delBatchResp) - - s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) - require.Len(t, noTeamCertificatesResp.Certificates, 0) -} - func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { t := s.T() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index a3778d791c0..33d6603631b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -29711,6 +29711,408 @@ func (s *integrationEnterpriseTestSuite) TestQueryLabelsIncludeAll() { require.True(t, hasQueryFor(hostBoth.ID), "host with both required labels should match include_all query") } +func (s *integrationEnterpriseTestSuite) TestCertificatesSpecs() { + t := s.T() + ctx := context.Background() + + // create team + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Test Team"}) + require.NoError(t, err) + + // Create a test certificate authority + ca, err := s.ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{ + Type: string(fleet.CATypeCustomSCEPProxy), + Name: ptr.String("Test SCEP CA"), + URL: ptr.String("http://localhost:8080/scep"), + Challenge: ptr.String("test-challenge"), + }) + require.NoError(t, err) + + // invalid Fleet variable in subject name + var applyResp applyCertificateTemplateSpecsResponse + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "Invalid Template", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_NOT_VALID/OU=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusBadRequest, &applyResp) + + // test with non-existent team name + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "Invalid Team Template", + Team: "NonExistentTeam", + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusNotFound, &applyResp) + + activitiesBeforeInsert := s.listActivities() + + // valid templates - test team name (not team ID) + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "Template 1", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", + }, + { + Name: "Template 2", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusOK, &applyResp) + + // Only one activity per team + activitiesAfterInsert := s.listActivities() + require.Len(t, activitiesAfterInsert, len(activitiesBeforeInsert)+1, "expected exactly one new activity for the team") + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + fmt.Sprintf(`{"fleet_id": %d, "fleet_name": %q, "team_id": %d, "team_name": %q}`, team.ID, team.Name, team.ID, team.Name), + 0, + ) + + // list specs + var listCertifcatesResp listCertificateTemplatesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) + require.Len(t, listCertifcatesResp.Certificates, 2) + assert.ElementsMatch(t, []string{"Template 1", "Template 2"}, []string{listCertifcatesResp.Certificates[0].Name, listCertifcatesResp.Certificates[1].Name}) + + lastActivityID := s.lastActivityMatches("", "", 0) + + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "Template 1", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", + }, + { + Name: "Template 2", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusOK, &applyResp) + + // No new activities created + currentActivityID := s.lastActivityMatches("", "", 0) + assert.Equal(t, lastActivityID, currentActivityID, "no new activity should be created when re-applying same certificates") + + // Create a host to get certificate get by id endpoint + host, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("test-cert-node-key"), + UUID: "test-uuid-12345", + Hostname: "test-cert-host.local", + HardwareSerial: "TEST-SERIAL-67890", + TeamID: &team.ID, + }) + require.NoError(t, err) + + orbitNodeKey := uuid.New().String() + host.OrbitNodeKey = &orbitNodeKey + require.NoError(t, s.ds.UpdateHost(ctx, host)) + + savedCertificateTemplates, _, err := s.ds.GetCertificateTemplatesByTeamID(ctx, team.ID, fleet.ListOptions{Page: 0, PerPage: 10}) + require.NoError(t, err) + certID := savedCertificateTemplates[0].ID + + // Create a host_certificate_templates record for this host (simulating what happens during Android enrollment) + // The endpoint /api/fleetd/certificates/{id} requires a host_certificate_templates record to exist + err = s.ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ + { + HostUUID: host.UUID, + CertificateTemplateID: certID, + Status: fleet.CertificateTemplateDelivered, + OperationType: fleet.MDMOperationTypeInstall, + }, + }) + require.NoError(t, err) + + var getCertResp getDeviceCertificateTemplateResponse + + resp := s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusOK, map[string]string{ + "Authorization": fmt.Sprintf("Node key %s", orbitNodeKey), + }) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) + require.NoError(t, resp.Body.Close()) + require.Equal(t, getCertResp.Certificate.Status, fleet.CertificateTemplateFailed) // failed because no IDP user to replace variables + + // Add an IDP user for the host + err = s.ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ + { + HostID: host.ID, + Email: "test.user@example.com", + Source: fleet.DeviceMappingMDMIdpAccounts, + }, + }, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + + // Get certificate without node_key + resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusUnauthorized, nil) + require.NoError(t, resp.Body.Close()) + + // Get certificate with node_key (should return replaced variables) + resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", certID), nil, http.StatusOK, map[string]string{ + "Authorization": fmt.Sprintf("Node key %s", orbitNodeKey), + }) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, getCertResp.Certificate) + + assert.Contains(t, getCertResp.Certificate.SubjectName, "test.user@example.com") + assert.Contains(t, getCertResp.Certificate.SubjectName, "test-uuid-12345") + assert.Contains(t, getCertResp.Certificate.SubjectName, "TEST-SERIAL-67890") + + // Get certificate without host_uuid (should return subject with variables) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates/%d", certID), nil, http.StatusOK, &getCertResp) + require.NotNil(t, getCertResp.Certificate) + + assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_END_USER_IDP_USERNAME") + assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_UUID") + assert.Contains(t, getCertResp.Certificate.SubjectName, "$FLEET_VAR_HOST_HARDWARE_SERIAL") + + // batch delete certificate templates + var delBatchResp deleteCertificateTemplateSpecsResponse + s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{listCertifcatesResp.Certificates[0].ID, listCertifcatesResp.Certificates[1].ID}, + "team_id": team.ID, + }, http.StatusOK, &delBatchResp) + + // Verify activity was created for deleting certificates + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + fmt.Sprintf(`{"fleet_id": %d, "fleet_name": %q, "team_id": %d, "team_name": %q}`, team.ID, team.Name, team.ID, team.Name), + 0, + ) + + // list specs + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) + require.Empty(t, listCertifcatesResp.Certificates) + + activitiesBeforeNoTeam := s.listActivities() + + // certificate templates for "No team" + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "No Team Template 1", + Team: "No team", + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", + }, + { + Name: "No Team Template 2", + Team: "", + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL", + }, + { + Name: "No Team Template 3", + // No Team field, should default to empty string + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusOK, &applyResp) + + // Only one activity was created for "No team" + activitiesAfterNoTeam := s.listActivities() + require.Len(t, activitiesAfterNoTeam, len(activitiesBeforeNoTeam)+1, "expected exactly one new activity for no team") + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + `{"fleet_id": null, "fleet_name": null, "team_id": null, "team_name": null}`, + 0, + ) + + // list specs for "no team" (team_id 0) + var noTeamCertificatesResp listCertificateTemplatesResponse + s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) + require.Len(t, noTeamCertificatesResp.Certificates, 3) + certNames := []string{noTeamCertificatesResp.Certificates[0].Name, noTeamCertificatesResp.Certificates[1].Name, noTeamCertificatesResp.Certificates[2].Name} + assert.ElementsMatch(t, []string{"No Team Template 1", "No Team Template 2", "No Team Template 3"}, certNames) + + // Create a host + noTeamHost, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("test-cert-no-team-node-key"), + UUID: "test-no-team-uuid-12345", + Hostname: "test-cert-no-team-host.local", + HardwareSerial: "TEST-NO-TEAM-SERIAL", + TeamID: nil, // No team + }) + require.NoError(t, err) + + noTeamOrbitNodeKey := uuid.New().String() + noTeamHost.OrbitNodeKey = &noTeamOrbitNodeKey + require.NoError(t, s.ds.UpdateHost(ctx, noTeamHost)) + + // Add an IDP user for host + err = s.ds.ReplaceHostDeviceMapping(ctx, noTeamHost.ID, []*fleet.HostDeviceMapping{ + { + HostID: noTeamHost.ID, + Email: "no.team.user@example.com", + Source: fleet.DeviceMappingMDMIdpAccounts, + }, + }, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + + savedNoTeamCertTemplates, _, err := s.ds.GetCertificateTemplatesByTeamID(ctx, 0, fleet.ListOptions{Page: 0, PerPage: 10}) + require.NoError(t, err) + require.Len(t, savedNoTeamCertTemplates, 3) + noTeamCertID := savedNoTeamCertTemplates[0].ID + + // Create a host_certificate_templates record for this no-team host + err = s.ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ + { + HostUUID: noTeamHost.UUID, + CertificateTemplateID: noTeamCertID, + Status: fleet.CertificateTemplateDelivered, + OperationType: fleet.MDMOperationTypeInstall, + }, + }) + require.NoError(t, err) + + // Get certificate with orbit node_key (should return replaced variables) + var getNoTeamCertResp getDeviceCertificateTemplateResponse + resp = s.DoRawWithHeaders("GET", fmt.Sprintf("/api/fleetd/certificates/%d", noTeamCertID), nil, http.StatusOK, map[string]string{ + "Authorization": fmt.Sprintf("Node key %s", noTeamOrbitNodeKey), + }) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&getNoTeamCertResp)) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, getNoTeamCertResp.Certificate) + assert.Contains(t, getNoTeamCertResp.Certificate.SubjectName, "no.team.user@example.com") + assert.Contains(t, getNoTeamCertResp.Certificate.SubjectName, "test-no-team-uuid-12345") + + var profilesSummaryResp getMDMProfilesSummaryResponse + s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", nil, http.StatusOK, &profilesSummaryResp) + require.NotNil(t, profilesSummaryResp) + require.Equal(t, uint(0), profilesSummaryResp.Verified) + require.Equal(t, uint(0), profilesSummaryResp.Verifying) + require.Equal(t, uint(0), profilesSummaryResp.Failed) + require.Equal(t, uint(0), profilesSummaryResp.Pending) + + // creating a certificate + var createCertResp createCertificateTemplateResponse + s.DoJSON("POST", "/api/latest/fleet/certificates", map[string]any{ + "name": "POST No Team Cert", + "certificate_authority_id": ca.ID, + "subject_name": "CN=$FLEET_VAR_HOST_UUID", + // team_id intentionally omitted - should default to 0 + }, http.StatusOK, &createCertResp) + require.NotZero(t, createCertResp.ID) + require.Equal(t, "POST No Team Cert", createCertResp.Name) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &getCertResp) + require.NotNil(t, getCertResp.Certificate) + + // Delete is authorized properly + observerEmail := "observer-cert-test@fleetdm.com" + observerPwd := test.GoodPassword + observerUser := &fleet.User{ + Name: "Observer User", + Email: observerEmail, + GlobalRole: ptr.String(fleet.RoleObserver), + } + require.NoError(t, observerUser.SetPassword(observerPwd, 10, 10)) + _, err = s.ds.NewUser(ctx, observerUser) + require.NoError(t, err) + + // Switch to observer user + s.token = s.getCachedUserToken(observerEmail, observerPwd) + // just in case test fails, restore to admin + defer func() { s.token = s.getTestAdminToken() }() + + // Delete with observer + resp = s.Do("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{savedNoTeamCertTemplates[0].ID}, + "team_id": uint(0), // "No team" + }, http.StatusForbidden) + resp.Body.Close() + + // Switch back to admin + s.token = s.getTestAdminToken() + + // Verify the certificate still exists (wasn't deleted by observer) + s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) + found := false + for _, cert := range noTeamCertificatesResp.Certificates { + if cert.ID == savedNoTeamCertTemplates[0].ID { + found = true + break + } + } + require.True(t, found, "Certificate should not be deleted by observer user") + + // Cannot delete certificates from a different team + // Create team 2 + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Test Team 2"}) + require.NoError(t, err) + team2ID := team2.ID + // Create a certificate in team2 + var team2CertResp createCertificateTemplateResponse + s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{ + Name: "Team 2 Cert", + TeamID: team2ID, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_UUID", + }, http.StatusOK, &team2CertResp) + var forbiddenDelResp deleteCertificateTemplateSpecsResponse + // Delete with team 1 id and certificate from team 2 + s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{team2CertResp.ID}, + "team_id": team.ID, + }, http.StatusForbidden, &forbiddenDelResp) + var listTeam2Resp listCertificateTemplatesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team2ID), + nil, http.StatusOK, &listTeam2Resp) + require.Len(t, listTeam2Resp.Certificates, 1) + require.Equal(t, "Team 2 Cert", listTeam2Resp.Certificates[0].Name) + + var delResp deleteCertificateTemplateResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &delResp) + + deletedNoTeamCertID := noTeamCertificatesResp.Certificates[0].ID + var delBatchResp2 deleteCertificateTemplateSpecsResponse + s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{deletedNoTeamCertID}, + // team_id intentionally omitted - should default to 0 + }, http.StatusOK, &delBatchResp2) + + s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) + require.Len(t, noTeamCertificatesResp.Certificates, 2) + for _, cert := range noTeamCertificatesResp.Certificates { + require.NotEqual(t, deletedNoTeamCertID, cert.ID, "deleted certificate should not appear in list") + } + + s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{noTeamCertificatesResp.Certificates[0].ID, noTeamCertificatesResp.Certificates[1].ID}, + "team_id": uint(0), + }, http.StatusOK, &delBatchResp) + + s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp) + require.Empty(t, noTeamCertificatesResp.Certificates) +} + func (s *integrationEnterpriseTestSuite) TestOrgLogoUploadGitOpsAuth() { t := s.T() diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index f29b265fb8d..196e95cf03a 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -203,6 +203,7 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec Name string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec CertificateAuthorityName string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectName string +github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectAlternativeName string github.com/fleetdm/fleet/v4/server/fleet/AppConfig GitOpsConfig fleet.GitOpsConfig github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig GitopsModeEnabled bool github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig RepositoryURL string diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index 00431c842f0..941abd753c0 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -89,6 +89,7 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec Name string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec CertificateAuthorityName string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectName string +github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectAlternativeName string github.com/fleetdm/fleet/v4/server/fleet/TeamConfig Features fleet.Features github.com/fleetdm/fleet/v4/server/fleet/Features EnableHostUsers bool github.com/fleetdm/fleet/v4/server/fleet/Features EnableSoftwareInventory bool diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index 5ace5b3196e..e158cd08645 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -60,3 +60,4 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec Name string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec CertificateAuthorityName string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectName string +github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectAlternativeName string