Back

Simplifying Cloud Security with Nitric

nitric security banner
Ryan CartwrightRyan Cartwright

Ryan Cartwright

7 min read

Nitric is designed with security as a fundamental part of the framework. Security can often be an afterthought when development efficiency is prioritised. With Nitric, security is built into the development flow.

With the infrastructure, the business logic, and the permissions all contained in the code, the developer does not need to change context. Traditionally, these components are isolated, where a developer writes each component separately and then composes it in a Terraform script or similar. Co-locating each part of your application into a single file makes updating and reviewing permissions seamless, as all the change is happening in one place. Moreover, developers don't need to request for an operations or infrastructure team to add granular permissions in which they have no context. Hence, developers are able to reduce the burden on other teams, whilst simultaneously enhancing their efficiency.

Least privilege by default

The principle of least privilege states that privileges should only be given to those that require it to complete their task. If a resource does not require an access privilege, it should not have that privilege.

In a normal environment without Nitric, development teams can have a tendency to use wildcards when creating permissions. This avoids being highly specific (which can be fine in development and testing environments), however, broad permissions can compromise the security of a production environment. To combat this, access to permissions is frequently limited to the operations and infrastructure teams. This creates another problem where developers are blocked by permissions and the subsequent support tickets.

When defining Nitric resources, the default access level is zero access. Only after adding granular privileges can a resource perform actions on another resource.

In this example, an API and a bucket are created. The lambda is given write permissions by specifying writing when the bucket is made. A POST endpoint is made on the API where image data is written to a file. For this example, the API will be given lambda:InvokeFunction permissions for the lambda created and the lambda will have the s3:PutObject permission for the “secureBucket” s3 bucket created.

// fileUpload.ts

import { api, bucket } from '@nitric/sdk';

const imageApi = api("imageApi");
const secureBucket = bucket('secureBucket').for('writing');

imageApi.post("/image", async (ctx) => {
  const { imageData } = ctx.req.json();
	await secureBucket.file("secret-image.png").write(imageData);
};

Diagram showing the permissions created when deploying

By granting the “writing” permission, the function can perform write operations on files in the bucket but is unable to read or delete files. By simplifying the granular permissions into what action needs to be performed, the privileges are exactly the ones a function requires, no more, no less.

Simplifying these permissions abstracts away the need to sift through documentation to find the permissions required to perform a simple write. For example, Nitric will use the permission “writing” to apply the following necessary permissions for each of the major clouds:

AWS

s3.PutObject

GCP orgpolicy.policy.get storage.multipartUploads.abort storage.multipartUploads.create storage.multipartUploads.listParts storage.objects.create

Azure Microsoft.Storage/storageAccounts/blobServices/containers/write Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey/action Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write Microsoft.Storage/storageAccounts/blobServices/containers/blobs/add/action Microsoft.Storage/storageAccounts/blobServices/containers/blobs/move/action

After Nitric gathers the configuration, a list of actions and the functions they belong to is generated. This allows Nitric to create new IAM users with the corresponding permissions allowing the function to run and access the required resources.

API level security

Functions are created without public endpoints. Instead, all requests must be sent via an API. With the API having both logging and traceability at a single point, security events become much simpler to monitor.

By default, Nitric has no API authentication in place, however, it is recommended to set up JWT authentication for non-public endpoints to stop all unauthenticated requests. This is done using any OIDC-compatible providers such as Auth0, FusionAuth and AWS Cognito. Once implemented in the code, the deployment configuration for the API gateway in the cloud is done for you.

Open ID Connect (OIDC), is an extension of the OAuth 2.0 protocol which supplies authentication on top of OAuth’s authorisation. The difference is that authentication verifies who someone is, and authorization informs what someone can do. OIDC enables users to have a seamless experience across different applications, where JWTs are shared instead of user credentials. Nitric uses OIDC providers, as the standard configuration makes it easy for developers to implement, with a wide range of tooling to choose from and documentation to learn from.

When defining the security for your API, there are two required properties, the security and the security definitions. The security definitions can be provided to define the JWT requirements for your API such as the issuer and audiences. The security rules can be specified to outline the scopes required to access the API. Nitric uses this to configure the policies within the cloud APIs.

const galaxyApi = api('main', {
  security: {
    user: ['user.read'],
  },
  securityDefinitions: {
    user: {
      // May be extended to support other authentication other than 'jwt'
      kind: 'jwt',
      issuer: 'https://dev-abc123.us.auth0.com',
      audiences: ['https://test-security-definition/'],
    },
  },
})

This security can be global or it can be route specific. In the above example, there is a global security scope that all routes require a signed JWT with [user.read](http://user.read) permissions. However, in the example below, the API level security is overridden to require different JWT scopes.

galaxyApi.get('planets/unsecured-planet', async (ctx) => {}, {
  // override top level security, and apply no security to this route
  security: {},
})

galaxyApi.post('planets/unsecured-planet', async (ctx) => {}, {
  // override top level security to require user.write scope to access
  security: {
    user: ['user.write'],
  },
})

Cloud Specific Implementations

For each of the clouds, Nitric augments the APIs to natively validate and decode the JWTs. They each validate the claims differently, however, for each of them, the requests never hit the deployed functions and are only validated at the API level. Nitric does this all from the configuration that is specified in your code, meaning developers have no need to use the cloud directly to implement this feature.

For AWS, we use API Gateway JWT Authorisers which decode and validate the JWT. The following claims are checked:

  • kid the token must have a header claim that matches the key in the jwks_uri that signed the token.
  • iss (issuer)
  • aud (audience)
  • exp (expiration time)
  • nbf (not before)
  • iat (issued at)
  • scope

For GCP, we extend the OpenAPI definition with the security for each route. This is then deployed with the API gateway to authenticate the users with provided JWTs. The following claims are validated:

  • iss (issuer)
  • sub (subject)
  • aud (audience)
  • iat (issued at)
  • exp (expiration time)

For Azure, we implement policies to check all inbound traffic to the API. This is done in API management, where a <validate-jwt> policy is set to check that the claims match and the JWT is valid. The issuer, if it is OIDC compatible, will point to a [issuer].well-known/openid-configuration endpoint which is used to validate the JWT. The only required claim is aud (audience). After the Authorization header is checked, the header will be copied to a new header X-Forwarded-Authorization. The Authorization header is overwritten when it is handled by the security validator within API management, so copying it is necessary so the function can still reference the JWT claims.

Building secure apps with Nitric

Implementing best security practices can be difficult, especially with deadlines and coordinating amongst infrastructure and development teams. This difficulty can often make it a lower priority compared with finishing the project. Using Nitric, the complexities of IAM and security are minimised, giving all the benefits of the cloud without the drawbacks of insecure resources.

Previous Post
Nitric Update - June 2023
Next Post
Using ent. with PlanetScale