Skip to content

Commit

Permalink
feat: Make authorization code pluggable (#102)
Browse files Browse the repository at this point in the history
* Support pluggable authz schemes.
  • Loading branch information
jgadling authored Nov 25, 2024
1 parent 189c558 commit 09218c4
Show file tree
Hide file tree
Showing 25 changed files with 641 additions and 196 deletions.
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
**/.mypy_cache/
**/.ruff_cache/
**/.vscode/
**/.moto_recording
**/.moto_recording
2 changes: 1 addition & 1 deletion .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build and push docker image and package

on:
release:
types:
types:
- published
workflow_dispatch:

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/conventional-commits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: chanzuckerberg/github-actions/.github/actions/conventional-commits@main
- uses: chanzuckerberg/github-actions/.github/actions/conventional-commits@main
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
with:
release-type: python
token: ${{ steps.generate_token.outputs.token }}
bump-minor-pre-major: true
bump-minor-pre-major: true
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Platformics is a GraphQL API framework that relies on code generation to impleme
The libraries and tools that make Platformics work:

![image](docs/images/platformics_libs.svg)

### Links to these tools/libraries
- [LinkML](https://linkml.io/) - Schema modeling language
- [FastAPI](https://fastapi.tiangolo.com/) - Async HTTP router
Expand Down Expand Up @@ -60,6 +60,7 @@ The version in `pyproject.toml` is managed using [poetry-dynamic-versioning](htt
- [Work with platformics](docs/HOWTO-working-with-platformics.md)
- [Extend the generated API](docs/HOWTO-extend-generated-api.md)
- [Customize Codegen templates](docs/HOWTO-customize-templates.md)
- [Override Default Authorization Behaviors](docs/HOWTO-override-authorization.md)

## Contributing
This project adheres to the Contributor Covenant code of conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [email protected].
Expand Down
4 changes: 2 additions & 2 deletions docs/HOWTO-customize-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Platformics supports replacing one codegen template with another, either globall
2. Create a directory that will contain your overriden templates, such as `template_overrides`
3. Copy that template to your overrides directory with the same path relative to `templates` in the platformics repo. For example, if you want to override `platformics/codegen/templates/database/models/class_name.py.j2`, copy it to `template_overrides/database/models/class_name.py.j2`
4. Modify the template as much as you want
5. When you run codegen, include the overrides folder as a parameter to the codegen tool. For example, update the `codegen` target in the `Makefile` for your project directory to look like:
5. When you run codegen, include the overrides folder as a parameter to the codegen tool. For example, update the `codegen` target in the `Makefile` for your project directory to look like:
```
$(docker_compose_run) $(APP_CONTAINER) platformics api generate --schemafile ./schema/schema.yaml --template-override-paths template_overrides --output-prefix .
```
Expand All @@ -17,7 +17,7 @@ Platformics supports replacing one codegen template with another, either globall
2. Create a directory that will contain your overriden templates, such as `template_overrides`
3. Copy that template to your overrides directory with the same path relative to `templates` in the platformics repo, but the **filename needs to reflect the camel_case class name**. For example, if you want to override `platformics/codegen/templates/database/models/class_name.py.j2`, for a class called `MyData`, copy it to `template_overrides/database/models/my_data.py.j2`
4. Modify the template as much as you want
5. When you run codegen, include the overrides folder as a parameter to the codegen tool. For example, update the `codegen` target in the `Makefile` for your project directory to look like:
5. When you run codegen, include the overrides folder as a parameter to the codegen tool. For example, update the `codegen` target in the `Makefile` for your project directory to look like:
```
$(docker_compose_run) $(APP_CONTAINER) platformics api generate --schemafile ./schema/schema.yaml --template-override-paths template_overrides --output-prefix .
```
116 changes: 116 additions & 0 deletions docs/HOWTO-override-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# How To: Override Default Authorization

## Auth Principals

By default, Platformics reads user and role information from JWT's with a special structure:

```json
{
"sub": "USERID GOES HERE",
"project_claims": {
"member": [123, 456],
"owner": [789],
"viewer": [333]
}
}

```

However, this may not work for every use case - if your application needs to fetch user and role information from some other source (cookies, external databases, etc) then you'll need to replace Platformics' default behavior with your own. This is pretty straightforward though, since Platformics uses dependency injection to allow many of its default behaviors to be customized!

```python
# your_app/main.py

from platformics.settings import APISettings
from database import models
from fastapi import Depends
from platformics.api.core.deps import get_auth_principal
from platformics.security.authorization import Principal
from platformics.graphql_api.core.deps import get_settings, get_user_token
from platformics.security.token_auth import get_token_claims
from starlette.requests import Request

...

# Create and run app
app = get_app(settings, schema, models)


# This is a FastAPI Dependency (https://fastapi.tiangolo.com/tutorial/dependencies/) and can
# depend on any of platformics' built-in dependencies, or any extra dependencies you may choose
# to define!
def override_auth_principal(request: Request, settings: APISettings = Depends(get_settings), user_token: typing.optional[str] = Depends(get_user_token)) -> typing.Optional[Principal]:
if user_token:
claims = get_token_claims(user_token)
else:
claims = {"sub": "anonymous"}

# Create an anonymous auth scope if we don't have a logged in user!
return Principal(
claims["sub"[,
roles=["user"],
attr={
"user_id": claims["sub"],
"owner_projects": [],
"member_projects": [],
"service_identity": [],
# This value can be read from a secret or external db or anything you wish.
# It's just hardcoded here for brevity.
"viewer_projects": [444],
},
)

# This override ensures that every time the API tries to fetch information about a user and their
# roles, your code will be called instead of the Platformics built-in functionality.
app.dependency_overrides[get_auth_principal] = override_auth_principal

...
```

## Authorized Queries

Platformics generates authorized SQL queries via [Cerbos' SQLAlchemy](https://docs.cerbos.dev/cerbos/latest/recipes/orm/sqlalchemy/index.html) integration by default. If you need to add additional filters to queries, or even skip using Cerbos entirely, you'll need to extend the base `platformics.security.authorization.AuthzClient` class to suit your own needs, and update the app's dependencies to use your modified AuthzClient class instead:

```python
# your_app/main.py
import typing

from cerbos.sdk.model import Resource, ResourceDesc
from platformics.security.authorization import Principal, AuthzClient
from platformics.settings import APISettings
from sqlalchemy.sql import Select
from platformics.graphql_api.core.deps import get_authz_client
from fastapi import Depends

...

# You can override any subset of the following methods!
class CustomAuthzClient(AuthzClient):
def __init__(self, settings: APISettings):
# Set up your class
...

def can_create(self, resource, principal: Principal) -> bool:
# Return a boolean value representing whether the user has permission to create the resource
...

def can_update(self, resource, principal: Principal) -> bool:
# Return a boolean value representing whether the user has permission to update the resource
...

def get_resource_query(self, principal: Principal, action: AuthzAction, model_cls, relationship) -> Select:
# Return a SQLAlchemy query for the given model_cls with security filters already applied
...

def modify_where_clause(self, principal: Principal, action: AuthzAction, model_cls, where_clauses) -> Select:
# Add additional filters to a query before it is executed.
...

def get_customized_authz_client(settings: APISettings = Depends(get_settings)):
return CustomAuthzClient(settings)

# This override ensures that every time the API tries to fetch an authorization client
# roles, your code will be called instead of the Platformics built-in functionality.
app.dependency_overrides[get_authz_client] = get_customized_authz_client

...
10 changes: 5 additions & 5 deletions docs/HOWTO-working-with-platformics.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Notable files and subdirectories:
* `files.py` - GQL types, mutations, queries for files
* `codegen/`
* `lib/linkml_wrappers.py` - convenience functions for converting LinkML to generated code
* `templates/` - all Jinja templates for codegen. Entity-related templates can be overridden with [custom templates](https://github.com/chanzuckerberg/platformics/tree/main/platformics/docs/HOWTO-customize-templates.md).
* `templates/` - all Jinja templates for codegen. Entity-related templates can be overridden with [custom templates](https://github.com/chanzuckerberg/platformics/tree/main/platformics/docs/HOWTO-customize-templates.md).
* `generator.py` - script handling all logic of applying Jinja templates to LinkML schema to generate code
* `database/`
* `models/`
Expand All @@ -31,7 +31,7 @@ Notable files and subdirectories:
Notable files and subdirectories:
* `api/` - entrypoint for GQL API service
* `helpers/` - generated GQL types and helper functions for GROUPBY queries
* `types/` - generated GQL types
* `types/` - generated GQL types
* `mutations.py` - generated mutations (create, update, delete) for each entity type
* `queries.py` - generated queries (list and aggregate) for each entity type
* `schema.graphql` - GQL format schema
Expand All @@ -40,7 +40,7 @@ Notable files and subdirectories:
* `cerbos/` - generated access policies for user actions for each entity type
* `database/` - code related to establishing DB connections / sessions
* `migrations/` - alembic migrations
* `models/` - generated SQLAlchemy models
* `models/` - generated SQLAlchemy models
* `schema/`
* `schema.yaml` - LinkML schema used to codegen entity-related files
* `test_infra/`
Expand All @@ -59,7 +59,7 @@ Containers (`test_app/docker-compose.yml`)
* `platformics-db`: Postgres database
* `graphql-api`: API

When developing on `platformics` itself, running `make dev` will start all of the above containers, then stop the `graphql-api` container and start a new `dev-app` compose service.
When developing on `platformics` itself, running `make dev` will start all of the above containers, then stop the `graphql-api` container and start a new `dev-app` compose service.
The compose service called `dev-app` has the `platformics` directory in this repo mounted inside the `test_app` application as a sub-module, so it can be edited directly and be debugged via the VSCode debugger.
`graphql-api` and `dev-app` share a port, so the `graphql-api` container is stopped before starting the `dev-app` container.

Expand All @@ -77,4 +77,4 @@ For either of these two flows, the main app will be listening on port 9009 and d


### Queries
To view SQL logs for queries, set `DB_ECHO=true` in `docker-compose.yml`. Run `make start` or `docker compose up -d` to apply the change.
To view SQL logs for queries, set `DB_ECHO=true` in `docker-compose.yml`. Run `make start` or `docker compose up -d` to apply the change.
2 changes: 1 addition & 1 deletion docs/images/platformics_libs.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 14 additions & 1 deletion platformics/codegen/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import logging
import os
import re

import jinja2.ext
from jinja2 import Environment, FileSystemLoader
from linkml_runtime.utils.schemaview import SchemaView

Expand Down Expand Up @@ -122,6 +124,16 @@ def generate_entity_import_files(
print(f"... wrote {filename}")


def regex_replace(txt, rgx, val, ignorecase=False, multiline=False):
flag = 0
if ignorecase:
flag |= re.I
if multiline:
flag |= re.M
compiled_rgx = re.compile(rgx, flag)
return compiled_rgx.sub(val, txt)


def generate(schemafile: str, output_prefix: str, render_files: bool, template_override_paths: tuple[str]) -> None:
"""
Launch code generation
Expand All @@ -130,7 +142,8 @@ def generate(schemafile: str, output_prefix: str, render_files: bool, template_o
template_paths.append(
os.path.join(os.path.abspath(os.path.dirname(__file__)), "templates/"),
) # default template path
environment = Environment(loader=FileSystemLoader(template_paths))
environment = Environment(loader=FileSystemLoader(template_paths), extensions=[jinja2.ext.loopcontrols])
environment.filters["regex_replace"] = regex_replace
view = SchemaView(schemafile)
view.imports_closure()
wrapped_view = ViewWrapper(view)
Expand Down
2 changes: 2 additions & 0 deletions platformics/codegen/templates/database/migrations/env.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def run_migrations_offline() -> None:
)

with context.begin_transaction():
context.get_context()._ensure_version_table() # pylint: disable=protected-access
connection.execute(sa.sql.text("LOCK TABLE alembic_version IN ACCESS EXCLUSIVE MODE"))
context.run_migrations()


Expand Down
Loading

0 comments on commit 09218c4

Please sign in to comment.