Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Demo: Keycloak RBAC #183

Draft
wants to merge 38 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
9bc63db
Basic docker compose setup
alukach Mar 5, 2024
059b385
Use OPL namespaces
alukach Mar 5, 2024
f4f68b2
Fix bad collection
alukach Mar 5, 2024
30c6f97
Fix collection name
alukach Mar 5, 2024
b59a1bf
Fixup namespaces
alukach Mar 5, 2024
2d65cb1
Ignore history
alukach Mar 5, 2024
e9a386c
Working basic permissions schema + data
alukach Mar 5, 2024
67b67a8
Use SpiceDB rather than Keto
alukach Mar 9, 2024
c216c2a
Add basic frontend
alukach Mar 9, 2024
d005e8f
In progress UI
alukach Mar 9, 2024
cfc80d4
In progress
alukach Mar 15, 2024
eab3665
Plausible integration (#182)
alukach Mar 15, 2024
a59af79
Auto setup keycloak with realm
alukach Apr 2, 2024
2e98984
Configure reload for stac-fastapi
alukach Apr 2, 2024
9577445
Merge remote-tracking branch 'origin/main' into demo/spicedb-integration
alukach Apr 2, 2024
5f8911d
Use eoAPI custom codebase
alukach Apr 2, 2024
37b10ec
Basic keycloak bootstrapping
alukach Apr 2, 2024
11317ce
Working bootstrap with users + client
alukach Apr 2, 2024
ca0bc58
Refactor for cleanliness
alukach Apr 2, 2024
99a835f
Setup user profiles
alukach Apr 2, 2024
c5d1afc
Install JWT reqs
alukach Apr 2, 2024
ecafe85
Remove client authentication
alukach Apr 2, 2024
4014349
Rm unused file
alukach Apr 2, 2024
82ef5b2
Support scopes
alukach Apr 2, 2024
fe1c4d5
Docstring
alukach Apr 2, 2024
c071300
Cleanup
alukach Apr 2, 2024
cd7c0ea
cleanup
alukach Apr 2, 2024
f076e59
Buildout types
alukach Apr 2, 2024
5194a30
Add scope requirements
alukach Apr 2, 2024
1f669f5
Add STAC Item permissions
alukach Apr 2, 2024
429b8b1
Undo unrelated changes
alukach Apr 3, 2024
0b62835
Rm spicedb runtime code
alukach Apr 3, 2024
2fee7d8
Rm docker compose spicedb references
alukach Apr 3, 2024
0faee7b
Push keycloak config to env vars
alukach Apr 3, 2024
4b8a547
Revert unnecessary changes
alukach Apr 3, 2024
5d7f04a
Mv keycloak to docker-compose.custom, mv fixtures to demo
alukach Apr 3, 2024
d74d8b2
Fix keycloak bootstrap
alukach Apr 3, 2024
c230257
Fix lint
alukach Apr 3, 2024
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
2,281 changes: 2,281 additions & 0 deletions demo/keycloak/eoapi-realm.json

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions demo/keycloak/eoapi-users-0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"realm": "eoapi",
"users": [
{
"id": "59c5407b-6280-48b6-ae19-64fe593089c7",
"username": "alice",
"firstName": "Alice",
"lastName": "de Admin",
"email": "alice@example.com",
"emailVerified": true,
"createdTimestamp": 1712037625377,
"enabled": true,
"totp": false,
"credentials": [
{
"id": "78736ca9-4091-434f-a6cd-cd42834f6b40",
"type": "password",
"userLabel": "Super insecure password",
"createdDate": 1712038488886,
"secretData": "{\"value\":\"l8IN1eQ+y+F/BSJ9joEayhaGlQkL/peHedxs/mUNnjWbR/16wTl7VJHXzTJ00A7X9ufyeh0ytRFuC+wLtIy/Kg==\",\"salt\":\"26MxYn2HeZIQPdiszZz7Cg==\",\"additionalParameters\":{}}",
"credentialData": "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}"
}
],
"disableableCredentialTypes": [],
"requiredActions": [],
"realmRoles": ["default-roles-eoapi"],
"notBefore": 0,
"groups": ["/Admins"]
},
{
"id": "ebe7613f-377e-416d-9ef5-c990c5ddbe66",
"username": "bob",
"firstName": "Bob",
"lastName": "Dubois",
"email": "bob@example.com",
"emailVerified": true,
"createdTimestamp": 1712037633309,
"enabled": true,
"totp": false,
"credentials": [
{
"id": "d0068d70-f38d-4b16-b431-359ac37fca4e",
"type": "password",
"userLabel": "Super insecure password",
"createdDate": 1712038506484,
"secretData": "{\"value\":\"Zq6E843f5+pcCcznbh82JHPgy634GWhYFHfc74mVJc23mlVEmwslkBPT+czD7+fa5InyGWLPza5M1nX6FISYbA==\",\"salt\":\"ERYVR7EUVeqHywNi5wKMTA==\",\"additionalParameters\":{}}",
"credentialData": "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}"
}
],
"disableableCredentialTypes": [],
"requiredActions": [],
"realmRoles": ["default-roles-eoapi"],
"notBefore": 0,
"groups": []
}
]
}
20 changes: 20 additions & 0 deletions docker-compose.custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ services:
build:
context: .
dockerfile: dockerfiles/Dockerfile.stac
args:
- INSTALL_EXTRA=[jwt]
ports:
- "${MY_DOCKER_IP:-127.0.0.1}:8081:8081"
environment:
Expand Down Expand Up @@ -49,6 +51,10 @@ services:
# PgSTAC extensions
# - EOAPI_STAC_EXTENSIONS=["filter", "query", "sort", "fields", "pagination", "context", "transaction"]
# - EOAPI_STAC_CORS_METHODS='GET,POST,PUT,OPTIONS'
- KEYCLOAK_REALM=eoapi
- KEYCLOAK_HOST=http://localhost:8080
- KEYCLOAK_CLIENT_ID=stac-api
- KEYCLOAK_INTERNAL_HOST=http://keycloak:8080
depends_on:
- database
- raster
Expand Down Expand Up @@ -276,6 +282,20 @@ services:
command: postgres -N 500
volumes:
- ./.pgdata:/var/lib/postgresql/data

keycloak:
profiles:
- keycloak
image: quay.io/keycloak/keycloak:latest
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
- "9990:9990"
command: start-dev --import-realm
volumes:
- ./demo/keycloak:/opt/keycloak/data/import

networks:
default:
Expand Down
7 changes: 4 additions & 3 deletions dockerfiles/Dockerfile.stac
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ ARG PYTHON_VERSION=3.11

FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION}

ARG INSTALL_EXTRA=

ENV CURL_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt

COPY runtime/eoapi/stac /tmp/stac
RUN pip install /tmp/stac
RUN rm -rf /tmp/stac
COPY runtime/eoapi/stac /app/stac
RUN pip install -e "/app/stac${INSTALL_EXTRA}"

ENV MODULE_NAME eoapi.stac.app
ENV VARIABLE_NAME app
3 changes: 3 additions & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ repo_name: "developmentseed/eoAPI"
repo_url: "https://github.com/developmentseed/eoAPI"

extra:
analytics:
provider: plausible
domain: eoapi.dev
social:
- icon: "fontawesome/brands/github"
link: "https://github.com/developmentseed"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script defer data-domain="{{ config.extra.analytics.domain }}" src="https://plausible.io/js/script.outbound-links.js"></script>
42 changes: 40 additions & 2 deletions runtime/eoapi/stac/eoapi/stac/app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""FastAPI application using PGStac."""

from contextlib import asynccontextmanager
from typing import Annotated, Any, Dict

from eoapi.stac.config import ApiSettings, TilesApiSettings
from eoapi.stac.extension import TiTilerExtension
from eoapi.stac.extension import extensions_map as PgStacExtensions
from fastapi import FastAPI
from fastapi import FastAPI, Security
from fastapi.responses import ORJSONResponse
from stac_fastapi.api.app import StacApi
from stac_fastapi.api.models import create_get_request_model, create_post_request_model
Expand All @@ -19,6 +20,8 @@
from starlette.templating import Jinja2Templates
from starlette_cramjam.middleware import CompressionMiddleware

from .auth import KeycloakAuth

try:
from importlib.resources import files as resources_files # type: ignore
except ImportError:
Expand All @@ -32,6 +35,8 @@
tiles_settings = TilesApiSettings()
settings = Settings()

keycloak = KeycloakAuth()


@asynccontextmanager
async def lifespan(app: FastAPI):
Expand All @@ -54,7 +59,15 @@ async def lifespan(app: FastAPI):
GETModel = create_get_request_model(extensions)

api = StacApi(
app=FastAPI(title=api_settings.name, lifespan=lifespan),
app=FastAPI(
title=api_settings.name,
lifespan=lifespan,
swagger_ui_init_oauth={
"appName": "eoAPI",
"clientId": keycloak.client_id,
"usePkceWithAuthorizationCodeGrant": True,
},
),
title=api_settings.name,
description=api_settings.name,
settings=settings,
Expand Down Expand Up @@ -82,6 +95,25 @@ async def lifespan(app: FastAPI):
extension = TiTilerExtension()
extension.register(api.app, tiles_settings.titiler_endpoint)

for (method, path), scopes in {
("POST", "/collections"): ["stac:collection:create"],
("PUT", "/collections"): ["stac:collection:update"],
("DELETE", "/collections/{collection_id}"): ["stac:collection:delete"],
("POST", "/collections/{collection_id}/items"): ["stac:item:create"],
("PUT", "/collections/{collection_id}/items/{item_id}"): ["stac:item:update"],
("DELETE", "/collections/{collection_id}/items/{item_id}"): ["stac:item:delete"],
}.items():
api.add_route_dependencies(
[
{
"path": app.router.prefix + path,
"method": method,
"type": "http",
},
],
[Security(keycloak.scheme, scopes=scopes)],
)


@app.get("/index.html", response_class=HTMLResponse)
async def viewer_page(request: Request):
Expand All @@ -91,3 +123,9 @@ async def viewer_page(request: Request):
{"request": request, "endpoint": str(request.url).replace("/index.html", "")},
media_type="text/html",
)


@app.get("/user", tags=["auth"])
def get_user(user_token: Annotated[Dict[Any, Any], Security(keycloak.user_validator)]):
"""View auth token."""
return user_token
144 changes: 144 additions & 0 deletions runtime/eoapi/stac/eoapi/stac/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Auth module for Keycloak integration.
"""

from functools import cached_property
from typing import Annotated, Dict, Iterable, List, Optional, TypedDict

import jwt
import pydantic
from fastapi import HTTPException, Security, security, status


class KeycloakAuth(pydantic.BaseSettings):
"""
Keycloak Integration.
"""

realm: str
host: str
client_id: str
internal_host: Optional[str] = None

required_audience: Optional[str | Iterable[str]] = None
scopes: Dict[str, str] = pydantic.Field(default_factory=lambda: {})

class Config:
"""Pydantic Config"""

env_file = ".env"
env_prefix = "KEYCLOAK_"
keep_untouched = (cached_property,)

def _build_url(self, host: str):
return f"{host}/realms/{self.realm}/protocol/openid-connect"

@property
def user_validator(
self,
):
"""
FastAPI Security Dependency to validate auth token.
"""

def valid_user_token(
token_str: Annotated[str, Security(self.scheme)],
required_scopes: security.SecurityScopes,
) -> TokenPayload:
# Parse & validate token
try:
token = jwt.decode(
token_str,
self.jwks_client.get_signing_key_from_jwt(token_str).key,
algorithms=["RS256"],
audience=self.required_audience,
)
except jwt.exceptions.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Could not validate credentials: {e}",
headers={"WWW-Authenticate": "Bearer"},
) from e

# Validate scopes (if required)
for scope in required_scopes.scopes:
if scope not in token["scope"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={
"WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"'
},
)

return token

return valid_user_token

@property
def internal_keycloak_api(self):
"""
URL for requests to Keycloak made from within this service.

e.g. When fetching JWKS keys.
"""
return self._build_url(self.internal_host or self.host)

@property
def keycloak_api(self):
"""
URL for requests to Keycloak made from outside this service.

e.g. When performing OAuth2 Authorization Code flow from docs UI.
"""
return self._build_url(self.host)

@property
def scheme(self):
"""
FastAPI Security Scheme.
"""
return security.OAuth2AuthorizationCodeBearer(
authorizationUrl=f"{self.keycloak_api}/auth",
tokenUrl=f"{self.keycloak_api}/token",
scopes=self.scopes,
)

@cached_property
def jwks_client(self):
"""
PyJWKClient instance for fetching JWKS keys from Keycloak. Used when validating
JWTs.
"""
return jwt.PyJWKClient(f"{self.internal_keycloak_api}/certs")


class RealmAccess(TypedDict):
"""Realm Access."""

roles: List[str]


class TokenPayload(TypedDict):
"""Parsed Keycloak JWT"""

exp: int
iat: int
auth_time: int
jti: str
iss: str
sub: str
typ: str
azp: str
session_state: str
acr: str
allowed_origins: List[str]
realm_access: RealmAccess
scope: str
sid: str
email_verified: bool
name: str
preferred_username: str
given_name: str
family_name: str
email: str
1 change: 1 addition & 0 deletions runtime/eoapi/stac/eoapi/stac/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class _ApiSettings(pydantic.BaseSettings):
"fields",
"pagination",
"context",
"transaction",
]

@pydantic.validator("cors_origins")
Expand Down
4 changes: 4 additions & 0 deletions runtime/eoapi/stac/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ test = [
"pytest-asyncio",
"httpx",
]
jwt = [
"pyjwt",
"cryptography"
]

[build-system]
requires = ["pdm-pep517"]
Expand Down
Loading