Local-first dev with CAP Node.js - mocking auth
In this post I provider a taster of what's possible regarding mock auth in CAP Node.js local-first development.
This post is related to a talk I'm putting together:
Local-first development with CAP Node.js - mock all the things!
As developers we need to be free of distractions plus a tight and speedy development loop. But definitely not at the expense of ignoring or postponing important design decisions. CAP's mocking facilities abstracts us from much tedium and ceremony, allowing us to iterate fast on data, auth, messaging and remote services while we develop. This session shows you what, and how.
Introduction
There are a couple of aspects of development that come together for the perfect (positive) storm of focused rapid iteration that results in a solid and complete foundation for a production offering, from the outset. They are:
- a tight feedback loop within which we can iterate rapidly on design and implementation
- local-first facilities for everything we need to get going, with minimum setup and configuration
As part of this second aspect, being able to easily fold in key design requirements and real world facilities from the very start means that we don't avoid them, or put them off until it's too late. Instead, we can embrace and address them right from the start of the iteration cycles, and avoid the build-up of design debt. The CAP development kit includes tools and affordances that make this easy for us to do, in the form of mocking.
This particular post focuses on mocking "auth" (both authentication and authorization).
The mocked authentication strategy
The Authentication Strategies section of the Node.js Security topic in Capire explains the different strategies available, and the "mocked" strategy comes with pre-defined users that can be used, with their various levels of authorisations, to explore, define and test security-related constructs. This mock user configuration can be modified and extended too, but what comes out of the box is definitely enough to get started.
Working through an example
In the rest of this post, we'll work through an example of mocking auth, based on content in the auth/ directory of the talk repository.
Note that what's absent here is any form of auth implementation - all declarations available are automatically enforced by CAP's generic service providers.
The service definition
In srv/main.cds there's a single service defined, with a couple of entities that are simple projections on to the entities in the data model:
using northwind from '../db/schema';
service Main {
entity Products as projection on northwind.Products;
entity Categories as projection on northwind.Categories;
}
Starting a CAP server in local development mode with cds watch shows us that
the mocked authentication strategy is in play by default:
[cds] - using auth strategy { kind: 'mocked' }
[cds] - serving Main {
at: [ '/odata/v4/main' ],
decl: 'srv/main.cds:4'
}
As we haven't yet addressed any auth requirements in our CDS model, access is currently open to all, as we can see1:
; curl \
--include \
--url 'localhost:4004/odata/v4/Main/Products?$top=1'
HTTP/1.1 200 OK
OData-Version: 4.0
Content-Type: application/json; charset=utf-8
Content-Length: 105
{
"@odata.context": "$metadata#BasicProducts",
"value": [
{
"ID": 1,
"name": "Chai",
"supplier": "Exotic Liquids"
}
]
}
Examine the pre-defined users and their authorisations
We can take a look at the pre-defined user data that is defined for the mocked authentication strategy, with:
cds env requires.auth.users
which will emit something like this:
{
alice: { tenant: 't1', roles: [ 'admin' ] },
bob: { tenant: 't1', roles: [ 'cds.ExtensionDeveloper' ] },
carol: { tenant: 't1', roles: [ 'admin', 'cds.ExtensionDeveloper' ] },
dave: { tenant: 't1', roles: [ 'admin' ], features: [] },
erin: { tenant: 't2', roles: [ 'admin', 'cds.ExtensionDeveloper' ] },
fred: { tenant: 't2', features: [ 'isbn' ] },
me: { tenant: 't1', features: [ '*' ] },
yves: { roles: [ 'internal-user' ] },
'*': true
}
Restrict the service
Let's annotate the service with some basic role based access control (RBAC)
requirements - that of needing to authenticate, via the pseudo-role
authenticated-user. We can use the
@requires
annotation:
using northwind from '../db/schema';
@requires: 'authenticated-user'
service Main {
...
}
The same curl request as before now fails with an appropriate HTTP 401 status
code:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Users"
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Unauthorized
Authenticate with a pre-defined user
We can re-try the request with one of the pre-defined
users2; because the requirement is just for the
pseudo-role authenticated-user, we don't need any particular actual role
allocated to the user, we just need to be successfully authenticated (and so
identified) in this case:
; curl \
--user alice: \
--include \
--url 'localhost:4004/odata/v4/Main/Products?$top=1'
HTTP/1.1 200 OK
OData-Version: 4.0
Content-Type: application/json; charset=utf-8
Content-Length: 105
{
"@odata.context": "$metadata#BasicProducts",
"value": [
{
"ID": 1,
"name": "Chai",
"supplier": "Exotic Liquids"
}
]
}
Try some finer-grained access restrictions
With the @restrict we can define finer grained access requirements3 in privilege building blocks of this form:
{ grant:<events>, to:<roles>, where:<filter-condition> }
Let's now add privilege requirements for the Categories entity, like this:
using northwind from '../db/schema';
@requires: 'authenticated-user'
service Main {
entity Products as projection on northwind.Products;
@restrict: [
{
grant: 'WRITE',
to : 'buyer-admin'
},
{
grant: 'READ',
to : 'any'
}
]
entity Categories as projection on northwind.Categories;
}
This says that any (authenticated) user can read the categories, but only a
user with the buyer-admin role can perform "write"-semantic operations.
Confirm read operations are permitted
Let's check that "read"-semantic operations are allowed for authenticated users
(remember that the entity access is also governed by the authenticated-user
pseudo-role restriction on the service that contains it):
; curl \
--user alice: \
--include \
--url 'localhost:4004/odata/v4/Main/Categories?$top=1'
HTTP/1.1 200 OK
OData-Version: 4.0
Content-Type: application/json; charset=utf-8
Content-Length: 155
{
"@odata.context": "$metadata#Categories",
"value": [
{
"CategoryID": 1,
"CategoryName": "Beverages",
"Description": "Soft drinks, coffees, teas, beers, and ales"
}
]
}
Looks OK.
Try a write operation
Now for a "write"-semantic operation. Let's go big and try DELETE:
; curl \
--user alice: \
--include \
--request DELETE \
--url 'localhost:4004/odata/v4/Main/Categories/1'
HTTP/1.1 403 Forbidden
OData-Version: 4.0
Content-Type: application/json; charset=utf-8
Content-Length: 74
{
"error": {
"message": "Forbidden",
"code": "403",
"@Common.numericSeverity": 4
}
}
Alice, with the admin role, is denied.
Add a user and role to for the mocked strategy
We can also modify and add to the pre-defined user definitions for the mocked
authentication strategy. Let's do that by adding some configuration in a
separate .cdsrc.json file in the project, defining a new user Polly with
a couple of roles:
{
"cds": {
"requires": {
"auth": {
"users": {
"polly": {
"roles": [
"buyer-admin",
"head-office"
]
}
}
}
}
}
}
One of the roles here is buyer-admin, so let's now try authenticating the previous
request as this new user:
; curl \
--user polly: \
--include \
--request DELETE \
--url 'localhost:4004/odata/v4/Main/Categories/1'
HTTP/1.1 204 No Content
OData-Version: 4.0
Success!
Wrapping up
With the mocked authentication strategy, we can embrace and work on the important aspect of securing our app or service right from the very start. CAP makes it easy to do the right things here.
For more information, see the Authentication topic in Capire.
Footnotes
-
The JSON output in these examples has been pretty-printed for readability here.
-
The
--useroption forcurlallows us to specify a username and password separated by a colon, soalice:here is just the username combined with an empty password (there are no passwords for these users). If we'd just specified--user alicewithout a colon, thencurlwould have prompted us for a password - we could have then just pressed Enter but this is one step we can avoid. -
In fact,
@requiresis just a convenience shortcut for@restrict. The annotation@requires: 'authenticated-user'that we used earlier is equivalent to
@restrict: [ { grant: '*', to: 'authenticated-user' } ]
- ← Previous
CDS expressions in CAP - notes on Part 6