Skip to content

Commit

Permalink
[Authz] Added support for security route configuration option (elasti…
Browse files Browse the repository at this point in the history
…c#191973)

## Summary

Extended `KibanaRouteOptions` to include security configuration at the
route definition level.

## Security Config
To facilitate iterative development security config is marked as
optional for now.

- `authz` supports both simple configuration (e.g., single privilege
requirements) and more complex configurations that involve anyRequired
and allRequired privilege sets.
- `authc` property has been added and is expected to replace the
existing `authRequired` option. This transition will be part of an
upcoming deprecation process in scope of
elastic#191711
- For versioned routes, the `authc` and `authz` configurations can be
applied independently for each version, enabling version-specific
security configuration. If none provided for the specific version it
will fall back to the route root security option.
- Validation logic has been added that ensures only supported
configurations are specified.
- Existing `registerOnPostAuth` hook has been modified to incorporate
checks based on the new `authz` property in the security configuration.
- Comprehensive documentation will be added in the separate PR before
sunsetting new security configuration and deprecating old one.

## How to Test
You can modify any existing route or use the example routes below
### Routes

<details>
<summary><b>Route 1:
/api/security/authz_examples/authz_disabled</b></summary>

```javascript
router.get(
  {
    path: '/api/security/authz_examples/authz_disabled',
    security: {
      authz: {
        enabled: false,
        reason: 'This route is opted out from authorization',
      },
    },
    validate: false,
  },
  createLicensedRouteHandler(async (context, request, response) => {
    try {
      return response.ok({
        body: {
          message: 'This route is opted out from authorization',
        },
      });
    } catch (error) {
      return response.customError(wrapIntoCustomErrorResponse(error));
    }
  })
);
```
</details>

<details>
<summary><b>Route 2:
/api/security/authz_examples/simple_privileges_1</b></summary>

```javascript
router.get(
  {
    path: '/api/security/authz_examples/simple_privileges_1',
    security: {
      authz: {
        requiredPrivileges: ['manageSpaces', 'taskManager'],
      },
    },
    validate: false,
  },
  createLicensedRouteHandler(async (context, request, response) => {
    try {
      return response.ok({
        body: {
          authzResult: request.authzResult,
        },
      });
    } catch (error) {
      return response.customError(wrapIntoCustomErrorResponse(error));
    }
  })
);
```
</details>

<details>
<summary><b>Route 3:
/api/security/authz_examples/simple_privileges_2</b></summary>

```javascript
router.get(
  {
    path: '/api/security/authz_examples/simple_privileges_2',
    security: {
      authz: {
        requiredPrivileges: [
          'manageSpaces',
          {
            anyRequired: ['taskManager', 'features'],
          },
        ],
      },
    },
    validate: false,
  },
  createLicensedRouteHandler(async (context, request, response) => {
    try {
      return response.ok({
        body: {
          authzResult: request.authzResult,
        },
      });
    } catch (error) {
      return response.customError(wrapIntoCustomErrorResponse(error));
    }
  })
);
```
</details>

<details>
<summary><b>Versioned Route:
/internal/security/authz_versioned_examples/simple_privileges_1</b></summary>

```javascript
router.versioned
  .get({
    path: '/internal/security/authz_versioned_examples/simple_privileges_1',
    access: 'internal',
    enableQueryVersion: true,
  })
  .addVersion(
    {
      version: '1',
      validate: false,
      security: {
        authz: {
          requiredPrivileges: ['manageSpaces', 'taskManager'],
        },
        authc: {
          enabled: 'optional',
        },
      },
    },
    (context, request, response) => {
      return response.ok({
        body: {
          authzResult: request.authzResult,
          version: '1',
        },
      });
    }
  )
  .addVersion(
    {
      version: '2',
      validate: false,
      security: {
        authz: {
          requiredPrivileges: ['manageSpaces'],
        },
        authc: {
          enabled: true,
        },
      },
    },
    (context, request, response) => {
      return response.ok({
        body: {
          authzResult: request.authzResult,
          version: '2',
        },
      });
    }
  );
```
</details>



### Checklist
- [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

__Closes: https://github.com/elastic/kibana/issues/191710__
__Related: elastic#191712,
https://github.com/elastic/kibana/issues/191713__

### Release Note
Extended `KibanaRouteOptions` to include security configuration at the
route definition level.

---------

Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
3 people authored Sep 30, 2024
1 parent 1abd347 commit 9a7e912
Show file tree
Hide file tree
Showing 32 changed files with 1,540 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jest.mock('uuid', () => ({

import { RouteOptions } from '@hapi/hapi';
import { hapiMocks } from '@kbn/hapi-mocks';
import type { FakeRawRequest } from '@kbn/core-http-server';
import type { FakeRawRequest, RouteSecurity } from '@kbn/core-http-server';
import { CoreKibanaRequest } from './request';
import { schema } from '@kbn/config-schema';
import {
Expand Down Expand Up @@ -352,6 +352,153 @@ describe('CoreKibanaRequest', () => {
});
});

describe('route.options.security property', () => {
it('handles required authc: undefined', () => {
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security: { authc: undefined },
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.authRequired).toBe(true);
});

it('handles required authc: { enabled: undefined }', () => {
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security: { authc: { enabled: undefined } },
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.authRequired).toBe(true);
});

it('handles required authc: { enabled: true }', () => {
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security: { authc: { enabled: true } },
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.authRequired).toBe(true);
});
it('handles required authc: { enabled: false }', () => {
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security: { authc: { enabled: false } },
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.authRequired).toBe(false);
});

it(`handles required authc: { enabled: 'optional' }`, () => {
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security: { authc: { enabled: 'optional' } },
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.authRequired).toBe('optional');
});

it('handles required authz simple config', () => {
const security: RouteSecurity = {
authz: {
requiredPrivileges: ['privilege1'],
},
};
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security,
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.security).toEqual(security);
});

it('handles required authz complex config', () => {
const security: RouteSecurity = {
authz: {
requiredPrivileges: [
{
allRequired: ['privilege1'],
anyRequired: ['privilege2', 'privilege3'],
},
],
},
};
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
security,
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.security).toEqual(security);
});

it('handles required authz config for the route with RouteSecurityGetter', () => {
const security: RouteSecurity = {
authz: {
requiredPrivileges: [
{
allRequired: ['privilege1'],
anyRequired: ['privilege2', 'privilege3'],
},
],
},
};
const request = hapiMocks.createRequest({
route: {
settings: {
app: {
// security is a getter function only for the versioned routes
security: () => security,
},
},
},
});
const kibanaRequest = CoreKibanaRequest.from(request);

expect(kibanaRequest.route.options.security).toEqual(security);
});
});

describe('RouteSchema type inferring', () => {
it('should work with config-schema', () => {
const body = Buffer.from('body!');
Expand Down
27 changes: 27 additions & 0 deletions packages/core/http/core-http-router-server-internal/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
RawRequest,
FakeRawRequest,
HttpProtocol,
RouteSecurityGetter,
RouteSecurity,
} from '@kbn/core-http-server';
import {
ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM,
Expand All @@ -46,6 +48,12 @@ patchRequest();

const requestSymbol = Symbol('request');

const isRouteSecurityGetter = (
security?: RouteSecurityGetter | RecursiveReadonly<RouteSecurity>
): security is RouteSecurityGetter => {
return typeof security === 'function';
};

/**
* Core internal implementation of {@link KibanaRequest}
* @internal
Expand Down Expand Up @@ -137,6 +145,8 @@ export class CoreKibanaRequest<
public readonly httpVersion: string;
/** {@inheritDoc KibanaRequest.protocol} */
public readonly protocol: HttpProtocol;
/** {@inheritDoc KibanaRequest.authzResult} */
public readonly authzResult?: Record<string, boolean>;

/** @internal */
protected readonly [requestSymbol]!: Request;
Expand All @@ -159,6 +169,7 @@ export class CoreKibanaRequest<
this.id = appState?.requestId ?? uuidv4();
this.uuid = appState?.requestUuid ?? uuidv4();
this.rewrittenUrl = appState?.rewrittenUrl;
this.authzResult = appState?.authzResult;

this.url = request.url ?? new URL('https://fake-request/url');
this.headers = isRealReq ? deepFreeze({ ...request.headers }) : request.headers;
Expand Down Expand Up @@ -204,6 +215,7 @@ export class CoreKibanaRequest<
isAuthenticated: this.auth.isAuthenticated,
},
route: this.route,
authzResult: this.authzResult,
};
}

Expand Down Expand Up @@ -256,6 +268,7 @@ export class CoreKibanaRequest<
true, // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
access: this.getAccess(request),
tags: request.route?.settings?.tags || [],
security: this.getSecurity(request),
timeout: {
payload: payloadTimeout,
idleSocket: socketTimeout === 0 ? undefined : socketTimeout,
Expand All @@ -277,6 +290,13 @@ export class CoreKibanaRequest<
};
}

private getSecurity(request: RawRequest): RouteSecurity | undefined {
const securityConfig = ((request.route?.settings as RouteOptions)?.app as KibanaRouteOptions)
?.security;

return isRouteSecurityGetter(securityConfig) ? securityConfig(request) : securityConfig;
}

/** set route access to internal if not declared */
private getAccess(request: RawRequest): 'internal' | 'public' {
return (
Expand All @@ -289,6 +309,12 @@ export class CoreKibanaRequest<
return true;
}

const security = this.getSecurity(request);

if (security?.authc !== undefined) {
return security.authc?.enabled ?? true;
}

const authOptions = request.route.settings.auth;
if (typeof authOptions === 'object') {
// 'try' is used in the legacy platform
Expand Down Expand Up @@ -368,6 +394,7 @@ function isCompleted(request: Request) {
*/
function sanitizeRequest(req: Request): { query: unknown; params: unknown; body: unknown } {
const { [ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM]: __, ...query } = req.query ?? {};

return {
query,
params: req.params,
Expand Down
11 changes: 10 additions & 1 deletion packages/core/http/core-http-router-server-internal/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { RouteMethod, SafeRouteMethod } from '@kbn/core-http-server';
import type { RouteMethod, SafeRouteMethod, RouteConfig } from '@kbn/core-http-server';
import type { RouteSecurityGetter, RouteSecurity } from '@kbn/core-http-server';

export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
return method === 'get' || method === 'options';
}

/** @interval */
export type InternalRouteConfig<P, Q, B, M extends RouteMethod> = Omit<
RouteConfig<P, Q, B, M>,
'security'
> & {
security?: RouteSecurityGetter | RouteSecurity;
};
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,47 @@ describe('Router', () => {
);
});

it('throws if enabled security config is not valid', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
expect(() =>
router.get(
{
path: '/',
validate: false,
security: {
authz: {
requiredPrivileges: [],
},
},
},
(context, req, res) => res.ok({})
)
).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: array size is [0], but cannot be smaller than [1]"`
);
});

it('throws if disabled security config does not provide opt-out reason', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
expect(() =>
router.get(
{
path: '/',
validate: false,
security: {
// @ts-expect-error
authz: {
enabled: false,
},
},
},
(context, req, res) => res.ok({})
)
).toThrowErrorMatchingInlineSnapshot(
`"[authz.reason]: expected value of type [string] but got [undefined]"`
);
});

it('should default `output: "stream" and parse: false` when no body validation is required but not a GET', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
router.post({ path: '/', validate: {} }, (context, req, res) => res.ok({}));
Expand Down
Loading

0 comments on commit 9a7e912

Please sign in to comment.