Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/user/security/audit-logging.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ Possible values:
| *Field*
| *Description*

| `user.id`
| Unique identifier of the user across sessions (See {ref}/user-profile.html[user profiles]).

| `user.name`
| Login name of the user.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) {
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
elastic_cloud_user: false,
profile_uid: 'uid',
metadata: { _reserved: false },
...user,
};
Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/security/common/model/authenticated_user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export interface AuthenticatedUser extends User {
* Indicates whether user is authenticated via Elastic Cloud built-in SAML realm.
*/
elastic_cloud_user: boolean;

/**
* User profile ID of this user.
*/
profile_uid?: string;
}

export function isUserAnonymous(user: Pick<AuthenticatedUser, 'authentication_provider'>) {
Expand Down
4 changes: 4 additions & 0 deletions x-pack/plugins/security/server/audit/audit_events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ describe('#userLoginEvent', () => {
authenticationProvider: 'basic1',
authenticationType: 'basic',
sessionId: '123',
userProfileId: 'uid',
})
).toMatchInlineSnapshot(`
Object {
Expand All @@ -261,6 +262,7 @@ describe('#userLoginEvent', () => {
},
"message": "User [user] has logged in using basic provider [name=basic1]",
"user": Object {
"id": "uid",
"name": "user",
"roles": Array [
"user-role",
Expand Down Expand Up @@ -311,6 +313,7 @@ describe('#userLogoutEvent', () => {
userLogoutEvent({
username: 'elastic',
provider: { name: 'basic1', type: 'basic' },
userProfileId: 'uid',
})
).toMatchInlineSnapshot(`
Object {
Expand All @@ -327,6 +330,7 @@ describe('#userLogoutEvent', () => {
},
"message": "User [elastic] is logging out using basic provider [name=basic1]",
"user": Object {
"id": "uid",
"name": "elastic",
},
}
Expand Down
22 changes: 16 additions & 6 deletions x-pack/plugins/security/server/audit/audit_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,15 @@ export interface UserLoginParams {
authenticationProvider?: string;
authenticationType?: string;
sessionId?: string;
userProfileId?: string;
}

export function userLoginEvent({
authenticationResult,
authenticationProvider,
authenticationType,
sessionId,
userProfileId,
}: UserLoginParams): AuditEvent {
return {
message: authenticationResult.user
Expand All @@ -116,6 +118,7 @@ export function userLoginEvent({
outcome: authenticationResult.user ? 'success' : 'failure',
},
user: authenticationResult.user && {
id: userProfileId,
name: authenticationResult.user.username,
roles: authenticationResult.user.roles as string[],
},
Expand All @@ -137,21 +140,28 @@ export function userLoginEvent({
export interface UserLogoutParams {
username?: string;
provider: AuthenticationProvider;
userProfileId?: string;
}

export function userLogoutEvent({ username, provider }: UserLogoutParams): AuditEvent {
export function userLogoutEvent({
username,
provider,
userProfileId,
}: UserLogoutParams): AuditEvent {
return {
message: `User [${username}] is logging out using ${provider.type} provider [name=${provider.name}]`,
event: {
action: 'user_logout',
category: ['authentication'],
outcome: 'unknown',
},
user: username
? {
name: username,
}
: undefined,
user:
userProfileId || username
? {
id: userProfileId,
name: username,
}
: undefined,
kibana: {
authentication_provider: provider.name,
authentication_type: provider.type,
Expand Down
6 changes: 4 additions & 2 deletions x-pack/plugins/security/server/audit/audit_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const createAuditConfig = (settings: Partial<ConfigType['audit']>) => {
const config = createAuditConfig({ enabled: true });
const { logging } = coreMock.createSetup();
const http = httpServiceMock.createSetupContract();
const getCurrentUser = jest.fn().mockReturnValue({ username: 'jdoe', roles: ['admin'] });
const getCurrentUser = jest
.fn()
.mockReturnValue({ username: 'jdoe', roles: ['admin'], profile_uid: 'uid' });
const getSpaceId = jest.fn().mockReturnValue('default');
const getSID = jest.fn().mockResolvedValue('SESSION_ID');
const recordAuditLoggingUsage = jest.fn();
Expand Down Expand Up @@ -192,7 +194,7 @@ describe('#asScoped', () => {
event: { action: 'ACTION' },
kibana: { space_id: 'default', session_id: 'SESSION_ID' },
trace: { id: 'REQUEST_ID' },
user: { name: 'jdoe', roles: ['admin'] },
user: { id: 'uid', name: 'jdoe', roles: ['admin'] },
});
audit.stop();
});
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security/server/audit/audit_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export class AuditService {
...event,
user:
(user && {
id: user.profile_uid,
name: user.username,
roles: user.roles as string[],
}) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,33 @@ describe('AuthenticationService', () => {
expect(authenticate).toHaveBeenCalledWith(mockRequest);
});

it('sets authenticated state correctly with user profile id', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
const mockResponse = httpServerMock.createLifecycleResponseFactory();
const mockUser = mockAuthenticatedUser();
const mockAuthHeaders = { authorization: 'Basic xxx' };
const mockAuthResponseHeaders = { 'WWW-Authenticate': 'Negotiate' };

authenticate.mockResolvedValue(
AuthenticationResult.succeeded(
{ ...mockUser, profile_uid: 'USER_PROFILE_ID' },
{
authHeaders: mockAuthHeaders,
authResponseHeaders: mockAuthResponseHeaders,
}
)
);

await authHandler(mockRequest, mockResponse, mockAuthToolkit);

expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({
state: { ...mockUser, profile_uid: 'USER_PROFILE_ID' },
requestHeaders: mockAuthHeaders,
responseHeaders: mockAuthResponseHeaders,
});
});

it('redirects user if redirection is requested by the authenticator preserving authentication response headers if any', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
authenticate.mockResolvedValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { UserProfileGrant } from '../user_profile';
import { userProfileServiceMock } from '../user_profile/user_profile_service.mock';
import { AuthenticationResult } from './authentication_result';
import type { AuthenticatorOptions } from './authenticator';
import { Authenticator } from './authenticator';
import { Authenticator, enrichWithUserProfileId } from './authenticator';
import { DeauthenticationResult } from './deauthentication_result';
import type { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers';

Expand Down Expand Up @@ -379,6 +379,29 @@ describe('Authenticator', () => {
expectAuditEvents({ action: 'user_login', outcome: 'success' });
});

it('returns user enriched with user profile id.', async () => {
const request = httpServerMock.createKibanaRequest();
const user = mockAuthenticatedUser({ profile_uid: undefined });
mockOptions.session.create.mockResolvedValue(
sessionMock.createValue({
userProfileId: 'PROFILE_ID',
})
);

mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.succeeded(user, {
state: {}, // to ensure a new session is created
})
);

const result = await authenticator.login(request, { provider: { type: 'basic' }, value: {} });
expect(result.user).toEqual(
expect.objectContaining({
profile_uid: 'PROFILE_ID',
})
);
});

describe('user_login audit events', () => {
// Every other test case includes audit event assertions, but the user_login event is a bit special.
// We have these separate, detailed test cases to ensure that the session ID is included for user_login success events.
Expand Down Expand Up @@ -2560,3 +2583,65 @@ describe('Authenticator', () => {
});
});
});

describe('enrichWithUserProfileId', () => {
it('should enrich succeeded authentication results with user profile id', () => {
const authenticationResult = AuthenticationResult.succeeded(
mockAuthenticatedUser({ profile_uid: undefined })
);
const sessionValue = sessionMock.createValue({ userProfileId: 'uid' });
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toEqual(
expect.objectContaining({
user: expect.objectContaining({
profile_uid: 'uid',
}),
})
);
});

it('should enrich redirected authentication results with user profile id', () => {
const authenticationResult = AuthenticationResult.redirectTo('/redirect/to', {
user: mockAuthenticatedUser({ profile_uid: undefined }),
});
const sessionValue = sessionMock.createValue({ userProfileId: 'uid' });
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toEqual(
expect.objectContaining({
user: expect.objectContaining({
profile_uid: 'uid',
}),
})
);
});

it('should not change unhandled authentication results', () => {
const authenticationResult = AuthenticationResult.notHandled();
const sessionValue = sessionMock.createValue();
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toBe(authenticationResult);
});

it('should not change failed authentication results', () => {
const authenticationResult = AuthenticationResult.failed(new Error('Authentication error'));
const sessionValue = sessionMock.createValue();
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toBe(authenticationResult);
});

it('should not change redirected authentication results without user', () => {
const authenticationResult = AuthenticationResult.redirectTo('/redirect/to');
const sessionValue = sessionMock.createValue();
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toBe(authenticationResult);
});

it('should not change succeeded authentication result if session has no user profile id', () => {
const authenticationResult = AuthenticationResult.succeeded(mockAuthenticatedUser());
const sessionValue = sessionMock.createValue({ userProfileId: undefined });
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toBe(authenticationResult);
});

it('should not change succeeded authentication result if user profile ids already match', () => {
const authenticationResult = AuthenticationResult.succeeded(
mockAuthenticatedUser({ profile_uid: 'uid' })
);
const sessionValue = sessionMock.createValue({ userProfileId: 'uid' });
expect(enrichWithUserProfileId(authenticationResult, sessionValue)).toBe(authenticationResult);
});
});
Loading