Skip to content

gitops, basic apis, and table for android certificate templates - #35788

Merged
ksykulev merged 38 commits into
mainfrom
35460-certificates
Nov 24, 2025
Merged

gitops, basic apis, and table for android certificate templates#35788
ksykulev merged 38 commits into
mainfrom
35460-certificates

Conversation

@ksykulev

@ksykulev ksykulev commented Nov 14, 2025

Copy link
Copy Markdown
Contributor

Related issue: Resolves #35460, #35462

Checklist for submitter

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements)

Testing

Database migrations

  • Checked schema for all modified table for columns that will auto-update timestamps during migration.
  • Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.
  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

Summary by CodeRabbit

Release Notes

  • New Features
    • Added certificate templates for managing Android device certificates at global and team levels
    • Introduced API endpoints to create, list, retrieve, and delete certificate templates
    • Enabled GitOps workflow support for certificate template specifications
    • Implemented automatic variable substitution in certificate subjects for host identifiers

✏️ Tip: You can customize this high-level summary in your review settings.

UNIQUE KEY idx_cert_team_name (team_id, name),
FOREIGN KEY (team_id) REFERENCES teams (id),
FOREIGN KEY (certificate_authority_id) REFERENCES certificate_authorities (id)
) DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@getvictor @marko-lisica I wasn't at the spec meetings and there isn't a ton of detail on the tickets, so trying to understand if I am correctly interpreting what is needed for this new certificate flow.

I see a bunch of other tables in our db such as scep_certificates and wstep_certificates. I assume these certificates are different. Is certificates the correct name for this table? Or should it be something like custom_certificates?
Is the FK to the certificate_authorities table is correct?
The unique constraint on team_id and name?
Do I need a platform column on here? (android, macos, windows... etc)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would say the table shoudl be called like certificate_templates since we are not storing actual certificates but information how to get them. We could also call it certificate_profiles if that's closer to Fleet vocabulary. I wouldn't worry about platform column until we need one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍 sounds good thank you for the guidance.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think certificate_templates makes sense. It's similar to profile, except that it's not executed on the host. It just provides information to our agent app to build CSR. I think other tools call this certificate template.

@codecov

codecov Bot commented Nov 17, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.29358% with 208 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.96%. Comparing base (89a149d) to head (e0d4036).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
server/service/certificates.go 49.70% 61 Missing and 23 partials ⚠️
server/service/client.go 10.25% 68 Missing and 2 partials ⚠️
server/service/client_android_certificates.go 0.00% 18 Missing ⚠️
server/datastore/mysql/certificate_templates.go 90.00% 6 Missing and 6 partials ⚠️
pkg/spec/gitops.go 0.00% 7 Missing and 1 partial ⚠️
server/service/service_errors.go 0.00% 7 Missing ⚠️
server/service/client_certificate_authorities.go 0.00% 5 Missing ⚠️
.../20251124140138_CreateTableCertifcatesTemplates.go 90.47% 2 Missing ⚠️
server/fleet/app.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #35788      +/-   ##
==========================================
- Coverage   65.99%   65.96%   -0.04%     
==========================================
  Files        2125     2130       +5     
  Lines      180946   181374     +428     
  Branches     7521     7419     -102     
==========================================
+ Hits       119419   119645     +226     
- Misses      50608    50777     +169     
- Partials    10919    10952      +33     
Flag Coverage Δ
backend 67.55% <52.29%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread server/service/certificates.go Outdated
func (svc *Service) ApplyCertificateTemplateSpecs(ctx context.Context, specs []*fleet.CertificateRequestSpec) error {
// TODO: What is the right authorization here?
// svc.authz.Authorize(ctx, &fleet.Certificate{TeamID: tmID}, fleet.ActionWrite) ?
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@marko-lisica @getvictor what do permissions look like around this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ksykulev Permissions changes: Already covered by "Edit OS settings" rows in permissions.

@ksykulev ksykulev changed the title gitops, basic apis, and table for android certificates gitops, basic apis, and table for android certificate templates Nov 18, 2025
Comment thread server/service/certificates.go Outdated

type deleteCertificateTemplateSpecsRequest struct {
IDs []uint `json:"ids"`
// TeamID uint `json:"team_id"` ??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm wondering if I should pass a team into the delete request like I do to the batch apply. That would make this endpoint more safe by disallowing users to delete certificates on teams outside of their own.

s.DoJSON("GET", fmt.Sprintf("/api/v1/fleetd/certificates/%d", certID), nil, http.StatusBadRequest, &getCertResp)

// Get certificate with node_key (should return replaced variables)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleetd/certificates/%d?node_key=%s", certID, *host.NodeKey), nil, http.StatusOK, &getCertResp)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Were we going to put node_key in the Authorization header? Putting it as a URL param exposes it in logs/OTEL/etc.
cc: @mostlikelee @sgress454

Can be fixed in a subsequent PR.

getvictor
getvictor previously approved these changes Nov 24, 2025

@sgress454 sgress454 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking good, mainly nits! Only functional change is, we can't bail out early in GitOps if there's no certs listed, because we need to be able to delete all the certs by removing them from GitOps.

Comment on lines +99 to +106
type getDeviceCertificateTemplateRequest struct {
ID uint `url:"id"`
NodeKey string `query:"node_key"`
}

func (r *getDeviceCertificateTemplateRequest) hostNodeKey() string {
return r.NodeKey
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't have much of an opinion here, but the docs have us checking an Authorization: Node key <node_key> header and @getvictor indicated that was best practice.

Comment thread pkg/spec/gitops.go
Comment on lines +767 to +779
if androidSettings.Certificates.Valid {
for i, cert := range androidSettings.Certificates.Value {
if cert.Name == "" {
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: name is required", i))
}
if cert.CertificateAuthorityName == "" {
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: certificate_authority_name is required", i))
}
if cert.SubjectName == "" {
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: subject_name is required", i))
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like a good idea...

Comment on lines +50 to +52
ORDER BY certificate_templates.id ASC
LIMIT ? OFFSET ?
`, teamID, perPage+1, perPage*page,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, we have fleet.ListOptions and appendListOptionsWithCursorToSQL() to streamline pagination, I only know about this because Lucas gave me the same comment when I did the same thing 😆 . See ListSecretVariables for example. Not worth blocking over but good to keep in mind.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hmm, I didn't know that method existed. But do we still need to implement the full text match in the where clause?

Comment on lines +122 to +150
const argsCountInsertCertificate = 4

const sqlInsertCertificate = `
INSERT INTO certificate_templates (
name,
team_id,
certificate_authority_id,
subject_name
) VALUES %s
ON DUPLICATE KEY UPDATE
name = VALUES(name),
team_id = VALUES(team_id),
certificate_authority_id = VALUES(certificate_authority_id),
subject_name = VALUES(subject_name)
`

var placeholders strings.Builder
args := make([]interface{}, 0, len(certificateTemplates)*argsCountInsertCertificate)

for _, cert := range certificateTemplates {
args = append(args, cert.Name, cert.TeamID, cert.CertificateAuthorityID, cert.SubjectName)
placeholders.WriteString("(?,?,?,?),")
}

stmt := fmt.Sprintf(sqlInsertCertificate, strings.TrimSuffix(placeholders.String(), ","))

if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "upserting certificate_templates")
}

@sgress454 sgress454 Nov 24, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: If we add db tags to the CertificateTemplate struct type, we can use NamedExecContext here, something like:

	const sqlInsertCertificate = `
        INSERT INTO certificate_templates (
            name,
            team_id,
            certificate_authority_id,
            subject_name
        ) VALUES (:name, :team, :certificate_authority_id, :subject_name)
        ON DUPLICATE KEY UPDATE
            name = VALUES(name),
            team_id = VALUES(team_id),
            certificate_authority_id = VALUES(certificate_authority_id),
            subject_name = VALUES(subject_name)
    `

	if _, err := ds.writer(ctx).NamedExecContext(ctx, sqlInsertCertificate, certificateTemplates); err != nil {
		return ctxerr.Wrap(ctx, err, "upserting certificate_templates")
	}

Comment on lines +147 to +171
func (ds *Datastore) BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) error {
if len(certificateTemplateIDs) == 0 {
return nil
}

const sqlDeleteCertificateTemplates = `
DELETE FROM certificate_templates
WHERE id IN (%s)
`
var placeholders strings.Builder
args := make([]interface{}, 0, len(certificateTemplateIDs))

for _, id := range certificateTemplateIDs {
args = append(args, id)
placeholders.WriteString("?,")
}

stmt := fmt.Sprintf(sqlDeleteCertificateTemplates, strings.TrimSuffix(placeholders.String(), ","))

if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "deleting certificate_templates")
}

return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is gated where needed at the endpoint level?

if err := svc.authz.Authorize(ctx, &fleet.CertificateTemplate{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}

return listCertificateTemplatesResponse{Certificates: certificates, Meta: paginationMetaData}, nil
}

func (svc *Service) ListCertificateTemplates(ctx context.Context, teamID uint, page int, perPage int) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same nit as above re: using fleet.ListOptions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The reason I didn't want to use ListOptions, is because then I have to implement match queries and order by. : /

Comment on lines +138 to +140
if certificate.TeamID != 0 && (host.TeamID == nil || *host.TeamID != certificate.TeamID) {
return nil, fleet.NewPermissionError("host does not have access to this certificate template")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are "global" certificate templates available to all teams? If not, then seems like we need to update this slightly so that it's "either certificate.TeamID = 0 and host.TeamID is nil, OR host.TeamID matches certificate.TeamID"

Comment thread server/service/client.go
return nil, nil, err
}

if !incoming.IsNoTeam() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No certs for "no team" devices?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I honestly don't know the answer to this. @marko-lisica or @getvictor, do we want to support devices on "no team"?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, we do want to support Android hosts and certs on No team.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, all "OS settings" are available for "No team", including certificates.

Comment thread server/service/client.go
Comment on lines +2873 to +2884
if config.Controls.AndroidSettings == nil {
return nil
}

androidSettings, ok := config.Controls.AndroidSettings.(fleet.AndroidSettings)
if !ok {
return nil
}

if !androidSettings.Certificates.Valid || len(androidSettings.Certificates.Value) == 0 {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think we can take these shortcuts because we need to be able to clear out certs by having an empty certificates: key or even omitting that or the android_settings keys.

@sgress454

Copy link
Copy Markdown
Contributor

I'm gonna push up a new schema.sql and then merge this. I have a patch for the GitOps issue I mentioned above here that I can put in a separate PR (or add to mine), and we need another PR for generate-gitops, but in the interest of unblocking ongoing work we should get this code in.

sgress454
sgress454 previously approved these changes Nov 24, 2025
@ksykulev

ksykulev commented Nov 24, 2025

Copy link
Copy Markdown
Contributor Author

Todos to be addressed in separate PRs:

  • Move node_key to authentication header
  • modify gitops code to always send certificate templates, even if none exist in the yaml file
  • convert to using ListOptions
  • "no team"
  • does the /api/fleetd/certificates/{id:[0-9]+} return the fleet challenge? It will not be sent in the managedConfiguration json.
  • validate that we're only allowing legal FLEET_VAR_* variables here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deploy certificates on Android hosts via GitOps - Gitops config

4 participants