Skip to content

Commit

Permalink
[EDR Workflows][Fleet] Improve uninstall token validation in Fleet se…
Browse files Browse the repository at this point in the history
…tup (elastic#175679)

## Summary

This PR is a proposal for improving Uninstall Token validation inside
Fleet. Any feedback from @elastic/fleet team is very welcome 🙌

What it does:
- moves Uninstall Token generation and validation from Fleet Setup to
Fleet plugin start in order to not perform these steps every time `POST
api/fleet/setup` is called
- adds a summary to issues with uninstall tokens to Kibana logs
e.g.
```
[2024-01-30T12:53:31.803+01:00][ERROR][plugins.encryptedSavedObjects] Failed to decrypt attribute "token" of saved object "fleet-uninstall-tokens,fb652173-7e07-47c1-8f42-e469d789d7ca": Unsupported state or unable to authenticate data
[2024-01-30T12:53:31.885+01:00][ERROR][plugins.encryptedSavedObjects] Failed to decrypt attribute "token" of saved object "fleet-uninstall-tokens,2c34c587-f81c-4534-80e3-f45fb0d0c3f9": Unsupported state or unable to authenticate data
[2024-01-30T12:53:31.886+01:00][ERROR][plugins.encryptedSavedObjects] Failed to decrypt attribute "token" of saved object "fleet-uninstall-tokens,e4d8cf22-0d8d-43c6-b21a-e11e7aea9932": Unsupported state or unable to authenticate data
[2024-01-30T12:53:32.522+01:00][WARN ][plugins.fleet] Failed to decrypt 3 of 1130 Uninstall Token(s)
```

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
  • Loading branch information
gergoabraham authored Feb 1, 2024
1 parent 993c174 commit 17239f2
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 89 deletions.
53 changes: 53 additions & 0 deletions x-pack/plugins/fleet/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,9 @@ export class FleetPlugin
}
);

// initialize (generate/encrypt/validate) Uninstall Tokens asynchronously
this.initializeUninstallTokens();

this.fleetStatus$.next({
level: ServiceStatusLevels.available,
summary: 'Fleet is available',
Expand Down Expand Up @@ -698,4 +701,54 @@ export class FleetPlugin

return this.logger;
}

private async initializeUninstallTokens() {
try {
await this.generateUninstallTokens();
} catch (error) {
appContextService
.getLogger()
.error('Error happened during uninstall token generation.', { error: { message: error } });
}

try {
await this.validateUninstallTokens();
} catch (error) {
appContextService
.getLogger()
.error('Error happened during uninstall token validation.', { error: { message: error } });
}
}

private async generateUninstallTokens() {
const logger = appContextService.getLogger();

logger.debug('Generating Agent uninstall tokens');
if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
logger.warn(
'xpack.encryptedSavedObjects.encryptionKey is not configured, agent uninstall tokens are being stored in plain text'
);
}
await appContextService.getUninstallTokenService()?.generateTokensForAllPolicies();

if (appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) {
logger.debug('Checking for and encrypting plain text uninstall tokens');
await appContextService.getUninstallTokenService()?.encryptTokens();
}
}

private async validateUninstallTokens() {
const logger = appContextService.getLogger();
logger.debug('Validating uninstall tokens');

const unintallTokenValidationError = await appContextService
.getUninstallTokenService()
?.checkTokenValidityForAllPolicies();

if (unintallTokenValidationError) {
logger.warn(unintallTokenValidationError.error.message);
} else {
logger.debug('Uninstall tokens validation successful.');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ import { agentPolicyService } from '../../agent_policy';

import { UninstallTokenService, type UninstallTokenServiceInterface } from '.';

interface TokenSO {
id: string;
attributes: {
policy_id: string;
token?: string;
token_plain?: string;
};
created_at: string;
}

describe('UninstallTokenService', () => {
const now = new Date().toISOString();
const aDayAgo = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
Expand All @@ -41,7 +51,7 @@ describe('UninstallTokenService', () => {
let mockBuckets: any[] = [];
let uninstallTokenService: UninstallTokenServiceInterface;

function getDefaultSO(encrypted: boolean = true) {
function getDefaultSO(encrypted: boolean = true): TokenSO {
return encrypted
? {
id: 'test-so-id',
Expand All @@ -61,7 +71,7 @@ describe('UninstallTokenService', () => {
};
}

function getDefaultSO2(encrypted: boolean = true) {
function getDefaultSO2(encrypted: boolean = true): TokenSO {
return encrypted
? {
id: 'test-so-id-two',
Expand All @@ -81,6 +91,20 @@ describe('UninstallTokenService', () => {
};
}

const decorateSOWithError = (so: TokenSO) => ({
...so,
error: new Error('error reason'),
});

const decorateSOWithMissingToken = (so: TokenSO) => ({
...so,
attributes: {
...so.attributes,
token: undefined,
token_plain: undefined,
},
});

function getDefaultBuckets(encrypted: boolean = true) {
const defaultSO = getDefaultSO(encrypted);
const defaultSO2 = getDefaultSO2(encrypted);
Expand Down Expand Up @@ -227,6 +251,26 @@ describe('UninstallTokenService', () => {
}
);
});

it('throws error if token is missing', async () => {
const so = decorateSOWithMissingToken(getDefaultSO(canEncrypt));
mockCreatePointInTimeFinderAsInternalUser([so]);

await expect(uninstallTokenService.getToken(so.id)).rejects.toThrowError(
new UninstallTokenError(
'Invalid uninstall token: Saved object is missing the token attribute.'
)
);
});

it("throws error if there's a depcryption error", async () => {
const so = decorateSOWithError(getDefaultSO2(canEncrypt));
mockCreatePointInTimeFinderAsInternalUser([so]);

await expect(uninstallTokenService.getToken(so.id)).rejects.toThrowError(
new UninstallTokenError("Error when reading Uninstall Token with id 'test-so-id-two'.")
);
});
});

describe('getTokenMetadata', () => {
Expand Down Expand Up @@ -507,18 +551,9 @@ describe('UninstallTokenService', () => {
describe('check validity of tokens', () => {
const okaySO = getDefaultSO(canEncrypt);

const errorWithDecryptionSO2 = {
...getDefaultSO2(canEncrypt),
error: new Error('error reason'),
};
const missingTokenSO2 = {
...getDefaultSO2(canEncrypt),
attributes: {
...getDefaultSO2(canEncrypt).attributes,
token: undefined,
token_plain: undefined,
},
};
const errorWithDecryptionSO1 = decorateSOWithError(getDefaultSO(canEncrypt));
const errorWithDecryptionSO2 = decorateSOWithError(getDefaultSO2(canEncrypt));
const missingTokenSO2 = decorateSOWithMissingToken(getDefaultSO2(canEncrypt));

describe('checkTokenValidityForAllPolicies', () => {
it('returns null if all of the tokens are available', async () => {
Expand Down Expand Up @@ -578,20 +613,31 @@ describe('UninstallTokenService', () => {
uninstallTokenService.checkTokenValidityForAllPolicies()
).resolves.toStrictEqual({
error: new UninstallTokenError(
'Invalid uninstall token: Saved object is missing the token attribute.'
'Failed to validate Uninstall Tokens: 1 of 2 tokens are invalid'
),
});
});

it('returns error if token decryption gives error', async () => {
it('returns error if some of the tokens cannot be decrypted', async () => {
mockCreatePointInTimeFinderAsInternalUser([okaySO, errorWithDecryptionSO2]);

await expect(
uninstallTokenService.checkTokenValidityForAllPolicies()
).resolves.toStrictEqual({
error: new UninstallTokenError(
"Error when reading Uninstall Token with id 'test-so-id-two'."
),
error: new UninstallTokenError('Failed to decrypt 1 of 2 Uninstall Token(s)'),
});
});

it('returns error if none of the tokens can be decrypted', async () => {
mockCreatePointInTimeFinderAsInternalUser([
errorWithDecryptionSO1,
errorWithDecryptionSO2,
]);

await expect(
uninstallTokenService.checkTokenValidityForAllPolicies()
).resolves.toStrictEqual({
error: new UninstallTokenError('Failed to decrypt 2 of 2 Uninstall Token(s)'),
});
});

Expand All @@ -607,7 +653,7 @@ describe('UninstallTokenService', () => {
});

describe('checkTokenValidityForPolicy', () => {
it('returns empty array if token is available', async () => {
it('returns null if token is available', async () => {
mockCreatePointInTimeFinderAsInternalUser();

await expect(
Expand All @@ -616,28 +662,26 @@ describe('UninstallTokenService', () => {
});

it('returns error if token is missing', async () => {
mockCreatePointInTimeFinderAsInternalUser([okaySO, missingTokenSO2]);
mockCreatePointInTimeFinderAsInternalUser([missingTokenSO2]);

await expect(
uninstallTokenService.checkTokenValidityForPolicy(missingTokenSO2.attributes.policy_id)
).resolves.toStrictEqual({
error: new UninstallTokenError(
'Invalid uninstall token: Saved object is missing the token attribute.'
'Failed to validate Uninstall Tokens: 1 of 1 tokens are invalid'
),
});
});

it('returns error if token decryption gives error', async () => {
mockCreatePointInTimeFinderAsInternalUser([okaySO, errorWithDecryptionSO2]);
mockCreatePointInTimeFinderAsInternalUser([errorWithDecryptionSO2]);

await expect(
uninstallTokenService.checkTokenValidityForPolicy(
errorWithDecryptionSO2.attributes.policy_id
)
).resolves.toStrictEqual({
error: new UninstallTokenError(
"Error when reading Uninstall Token with id 'test-so-id-two'."
),
error: new UninstallTokenError('Failed to decrypt 1 of 1 Uninstall Token(s)'),
});
});

Expand Down
Loading

0 comments on commit 17239f2

Please sign in to comment.