diff --git a/docs/source/access-management/architecture/authorization-flow.md b/docs/source/access-management/architecture/authorization-flow.md new file mode 100644 index 000000000..a51566d8b --- /dev/null +++ b/docs/source/access-management/architecture/authorization-flow.md @@ -0,0 +1,154 @@ +# Authorization Flow + +The request flow through the CWMS Access Management system spans from initial client authentication through data retrieval. + +## Complete Authorization Sequence + +The following diagram shows the full request lifecycle including authentication, context retrieval, policy evaluation, and data filtering. + +```mermaid +sequenceDiagram + participant c as Client + participant kc as Keycloak + participant proxy as Authorization Proxy + participant redis as Redis + participant opa as OPA + participant api as CWMS Data API + participant db as Oracle Database + + c->>kc: Authenticate with credentials + kc-->>c: JWT Token + + c->>proxy: Request + JWT + proxy->>proxy: Extract username from JWT + + proxy->>redis: Check cache for user context + alt Cache Hit + redis-->>proxy: User context + else Cache Miss + proxy->>api: GET /user/profile + JWT + api->>kc: Validate JWT via JWKS + api->>db: Query principle_name mapping + api->>db: Query offices and roles + api-->>proxy: User profile + proxy->>redis: Cache user context (30 min TTL) + end + + proxy->>opa: Evaluate policy with user context + opa-->>proxy: Decision and constraints + + alt Request Allowed + proxy->>api: Forward request + x-cwms-auth-context header + api->>db: Execute query with WHERE constraints + db-->>api: Filtered data + api-->>proxy: Response + proxy-->>c: Response + else Request Denied + proxy-->>c: 403 Forbidden + end +``` + +## Flow Phases + +### Phase 1: Authentication + +The client authenticates with Keycloak using their credentials. Upon successful authentication, Keycloak issues a JWT token containing: + +- Issuer claim identifying the Keycloak realm +- Subject claim with the user's unique identifier +- Expiration and other standard JWT claims + +The client includes this token in subsequent requests to the authorization proxy. + +### Phase 2: User Context Resolution + +When the proxy receives a request, it extracts the username from the JWT token and attempts to retrieve the user's context. + +| Step | Description | +|------|-------------| +| Cache Check | Proxy queries Redis for cached user context | +| Profile Request | On cache miss, proxy requests user profile from CWMS Data API | +| JWT Validation | API validates the JWT using Keycloak's JWKS endpoint | +| Identity Mapping | API maps the JWT subject to a CWMS database user | +| Context Assembly | API queries user offices and roles from database | +| Cache Storage | Proxy caches the context in Redis for 30 minutes | + +The user context includes: + +- User identifier and username +- Assigned roles from the CWMS security groups +- Office affiliations and primary office +- Any additional profile attributes + +### Phase 3: Policy Evaluation + +The proxy sends the user context along with request details to OPA for policy evaluation. + +```mermaid +sequenceDiagram + participant proxy as Authorization Proxy + participant opa as OPA + + proxy->>opa: Policy input + Note right of proxy: user context, HTTP method, path, parameters + opa->>opa: Evaluate Rego policies + opa-->>proxy: Policy decision + Note left of opa: allow, constraints, embargo rules +``` + +OPA evaluates the request against configured policies and returns: + +| Field | Description | +|-------|-------------| +| `allow` | Boolean indicating whether request is permitted | +| `allowed_offices` | Array of office IDs the user can access | +| `embargo_rules` | Time-based restrictions per office | +| `embargo_exempt` | Whether user bypasses embargo restrictions | +| `time_window` | Historical data access limitations | +| `data_classification` | Classification levels the user can access | + +### Phase 4: Request Forwarding + +If the policy allows the request, the proxy constructs the `x-cwms-auth-context` header and forwards the request to the CWMS Data API. + +The header contains the complete authorization context in JSON format, enabling the API to apply filtering without making additional authorization queries. + +### Phase 5: Server-Side Filtering + +The CWMS Data API parses the authorization context header and applies constraints at the database query level. + +```mermaid +sequenceDiagram + participant proxy as Authorization Proxy + participant api as CWMS Data API + participant db as Oracle Database + + proxy->>api: Request + x-cwms-auth-context + Note over api: Parse header constraints + api->>db: SELECT * WHERE office_id IN ('SWT', 'SPK') + Note over db: Only matching records returned + db-->>api: Filtered results + api-->>proxy: Response with filtered data +``` + +The API uses the `AuthorizationFilterHelper` class to generate JOOQ conditions that are added to SQL WHERE clauses. This ensures filtering happens at the database level, preventing unauthorized data from ever leaving the database. + +## Whitelist Bypass Flow + +For endpoints not on the OPA whitelist, the proxy bypasses policy evaluation and forwards requests directly. + +```mermaid +sequenceDiagram + participant c as Client + participant proxy as Authorization Proxy + participant api as CWMS Data API + + c->>proxy: Request to non-whitelisted endpoint + proxy->>proxy: Check whitelist + Note over proxy: Endpoint not whitelisted + proxy->>api: Forward request directly + api-->>proxy: Response + proxy-->>c: Response +``` + +This allows administrative or health check endpoints to function without authorization overhead while still benefiting from the proxy infrastructure. diff --git a/docs/source/access-management/architecture/component-diagram.md b/docs/source/access-management/architecture/component-diagram.md new file mode 100644 index 000000000..3f8b8bc0e --- /dev/null +++ b/docs/source/access-management/architecture/component-diagram.md @@ -0,0 +1,245 @@ +# Component Diagram + +The CWMS Access Management system consists of several interconnected components that work together to provide authorization services. + +## System Component Diagram + +```mermaid +graph TB + subgraph External + client[Client Application] + end + + subgraph Access Management Layer + proxy[Authorization Proxy] + opa[Open Policy Agent] + redis[Redis Cache] + mgmt_ui[Management UI] + mgmt_api[Management API] + end + + subgraph Identity Layer + keycloak[Keycloak] + end + + subgraph Data Layer + cda[CWMS Data API] + db[(Oracle Database)] + end + + client --> proxy + proxy --> opa + proxy --> redis + proxy --> cda + proxy --> keycloak + cda --> keycloak + cda --> db + mgmt_ui --> mgmt_api + mgmt_api --> opa + mgmt_api --> keycloak +``` + +## Component Details + +### Authorization Proxy + +The authorization proxy is the entry point for all client requests to the CWMS Data API. + +```mermaid +graph LR + subgraph Authorization Proxy + router[Request Router] + jwt[JWT Parser] + cache[Cache Client] + policy[Policy Client] + context[Context Builder] + forward[Request Forwarder] + end + + router --> jwt + jwt --> cache + cache --> policy + policy --> context + context --> forward +``` + +| Subcomponent | Responsibility | +|--------------|----------------| +| Request Router | Routes incoming requests based on whitelist configuration | +| JWT Parser | Extracts user identity from JWT tokens | +| Cache Client | Interfaces with Redis for user context caching | +| Policy Client | Communicates with OPA for policy evaluation | +| Context Builder | Constructs the x-cwms-auth-context header | +| Request Forwarder | Forwards authorized requests to the backend API | + +### Open Policy Agent + +OPA provides policy-based authorization decisions using Rego policies. + +```mermaid +graph TB + subgraph OPA + engine[Policy Engine] + policies[Policy Bundle] + end + + subgraph Policies + main[cwms_authz.rego] + personas[Persona Policies] + helpers[Helper Functions] + end + + engine --> policies + policies --> main + main --> personas + main --> helpers +``` + +Policy structure: + +| Policy File | Purpose | +|-------------|---------| +| cwms_authz.rego | Main orchestrator policy | +| personas/public.rego | Anonymous access rules | +| personas/dam_operator.rego | Operational staff rules | +| personas/water_manager.rego | Management staff rules | +| personas/data_manager.rego | Regional manager rules | +| personas/automated_collector.rego | Data collection system rules | +| personas/automated_processor.rego | Data processing system rules | +| personas/external_cooperator.rego | External partner rules | +| helpers/offices.rego | Office metadata and relationships | +| helpers/time_rules.rego | Embargo and time window rules | + +### Redis Cache + +Redis stores user context to reduce database queries and improve response times. + +| Configuration | Value | +|---------------|-------| +| Key Format | `user:context:{username}` | +| TTL | 1800 seconds (30 minutes) | +| Max Memory | 256 MB | +| Eviction Policy | allkeys-lru | +| Persistence | AOF (append-only file) | + +### CWMS Data API + +The Java backend API provides data access with authorization filtering. + +```mermaid +graph TB + subgraph CWMS Data API + endpoints[REST Endpoints] + filter[Authorization Filter] + helper[AuthorizationFilterHelper] + jooq[JOOQ Query Builder] + end + + subgraph Database Access + db[(Oracle Database)] + end + + endpoints --> filter + filter --> helper + helper --> jooq + jooq --> db +``` + +| Component | Responsibility | +|-----------|----------------| +| REST Endpoints | Handle HTTP requests for various data types | +| Authorization Filter | Intercepts requests to extract auth context | +| AuthorizationFilterHelper | Parses header and generates SQL conditions | +| JOOQ Query Builder | Constructs filtered SQL queries | + +### Keycloak + +Keycloak provides identity management and authentication services. + +| Feature | Usage | +|---------|-------| +| User Management | Stores user credentials and attributes | +| JWT Issuance | Issues tokens upon successful authentication | +| JWKS Endpoint | Provides public keys for token validation | +| Realm Configuration | Defines client applications and roles | + +### Management Components + +The management UI and API provide administrative interfaces for the access management system. + +| Component | Technology | Port | Purpose | +|-----------|------------|------|---------| +| Management UI | React 18, Vite, Tailwind | 4200 | Web-based policy management | +| Management API | Node.js, Fastify | 3002 | Backend for management operations | + +## Network Topology + +All components communicate over a shared container network. + +```mermaid +graph TB + subgraph cwmsdb_net + proxy[Authorization Proxy
Port 3001] + opa[OPA
Port 8181] + redis[Redis
Port 6379] + mgmt_ui[Management UI
Port 4200] + mgmt_api[Management API
Port 3002] + cda[CWMS Data API
Port 7001] + keycloak[Keycloak
Port 8080] + db[Oracle Database
Port 1521] + end + + proxy --> opa + proxy --> redis + proxy --> cda + mgmt_ui --> mgmt_api + mgmt_api --> opa + mgmt_api --> keycloak + cda --> db + cda --> keycloak +``` + +## Data Flow Summary + +| Flow | Path | Data | +|------|------|------| +| Client Request | Client to Proxy | JWT token, HTTP request | +| Context Lookup | Proxy to Redis | Username key | +| Profile Request | Proxy to API | JWT token | +| Policy Check | Proxy to OPA | User context, request details | +| Data Request | Proxy to API | Auth context header, original request | +| Database Query | API to Oracle | Filtered SQL query | + +## Dependency Graph + +```mermaid +graph TD + proxy[Authorization Proxy] + opa[OPA] + redis[Redis] + cda[CWMS Data API] + keycloak[Keycloak] + db[Oracle Database] + mgmt_ui[Management UI] + mgmt_api[Management API] + + proxy --> opa + proxy --> redis + proxy --> cda + cda --> keycloak + cda --> db + mgmt_ui --> mgmt_api + mgmt_api --> opa + mgmt_api --> keycloak +``` + +Startup order: + +1. Oracle Database +2. Keycloak (depends on database for persistence) +3. Redis (no dependencies) +4. OPA (no dependencies) +5. CWMS Data API (depends on database and Keycloak) +6. Authorization Proxy (depends on Redis, OPA, and CWMS Data API) +7. Management API (depends on OPA and Keycloak) +8. Management UI (depends on Management API) diff --git a/docs/source/access-management/architecture/index.md b/docs/source/access-management/architecture/index.md new file mode 100644 index 000000000..42f0cbb28 --- /dev/null +++ b/docs/source/access-management/architecture/index.md @@ -0,0 +1,131 @@ +# Architecture Overview + +The CWMS Access Management system uses a transparent proxy architecture to provide fine-grained authorization without modifying the core CWMS Data API. + +## High-Level Architecture + +```mermaid +graph TB + client[Client Application] + proxy[Authorization Proxy] + opa[Open Policy Agent] + redis[Redis Cache] + cda[CWMS Data API] + keycloak[Keycloak] + db[(Oracle Database)] + + client -->|JWT Token| proxy + proxy -->|Extract Username| keycloak + proxy -->|Policy Check| opa + proxy -->|Cache Lookup| redis + proxy -->|x-cwms-auth-context| cda + cda -->|Validate JWT| keycloak + cda -->|Filtered Queries| db +``` + +## Component Overview + +### Authorization Proxy + +The authorization proxy is a TypeScript application built with Fastify that intercepts all requests to the CWMS Data API. It serves as the central coordination point for authorization. + +| Aspect | Details | +|--------|---------| +| Technology | Node.js 24, TypeScript, Fastify | +| Port | 3001 | +| Function | Request interception, policy evaluation, context injection | + +Key responsibilities: + +- Extract JWT tokens from incoming requests +- Query user context from the CWMS Data API (with Redis caching) +- Evaluate authorization policies via OPA +- Construct and attach the `x-cwms-auth-context` header +- Forward authorized requests to the CWMS Data API + +The proxy uses a whitelist pattern where only specified endpoints are subject to OPA policy evaluation. Non-whitelisted endpoints bypass authorization checks and are forwarded directly. + +### Open Policy Agent + +OPA serves as the centralized policy decision point. All authorization logic resides in Rego policies evaluated by OPA. + +| Aspect | Details | +|--------|---------| +| Technology | OPA 0.68.0 | +| Port | 8181 | +| Function | Policy evaluation and constraint generation | + +Policy decisions include: + +- Whether the request is allowed (`allow: true/false`) +- Filtering constraints to apply at the database level +- Embargo rules for time-sensitive data +- Office-based access restrictions + +### Redis Cache + +Redis provides caching for user context to reduce database load and improve response times. + +| Aspect | Details | +|--------|---------| +| Technology | Redis 7.x | +| Port | 6379 | +| Function | User context caching | + +Cache characteristics: + +- Key format: `user:context:{username}` +- TTL: 1800 seconds (30 minutes) +- Performance improvement: 10x (2ms vs 20ms) +- Database load reduction: approximately 95% + +### CWMS Data API + +The Java-based CWMS Data API is the backend service that provides access to water management data stored in the Oracle database. + +| Aspect | Details | +|--------|---------| +| Technology | Java 11 | +| Port | 7001 | +| Function | Data retrieval with SQL-level filtering | + +The API parses the `x-cwms-auth-context` header and applies constraints at the SQL level using JOOQ conditions. The API does not make authorization decisions; it only enforces constraints specified by the authorization layer. + +### Keycloak + +Keycloak provides identity management and JWT token services. + +| Aspect | Details | +|--------|---------| +| Technology | Keycloak 19.0.1 | +| Port | 8080 | +| Function | Authentication, JWT issuance, token validation | + +Users authenticate with Keycloak and receive a JWT token. The token contains the issuer and subject claims used to map the user to their CWMS database identity. + +### Oracle Database + +The Oracle database stores all CWMS data along with user security information. + +| Aspect | Details | +|--------|---------| +| Technology | Oracle 23c Free | +| Port | 1521 | +| Function | Data storage and security metadata | + +Key tables and views: + +- `at_sec_cwms_users`: Maps user identities to CWMS offices +- `av_sec_users`: Provides user role information + +## Detailed Documentation + +- [Authorization Flow](authorization-flow.md): Sequence diagrams showing request processing +- [Component Diagram](component-diagram.md): Detailed component relationships + +```{toctree} +:maxdepth: 2 + +authorization-flow +component-diagram +``` diff --git a/docs/source/access-management/configuration/environment-variables.md b/docs/source/access-management/configuration/environment-variables.md new file mode 100644 index 000000000..54c576028 --- /dev/null +++ b/docs/source/access-management/configuration/environment-variables.md @@ -0,0 +1,199 @@ +# Environment Variables Reference + +Complete reference for all environment variables used by the CWMS Access Management system. + +## Java API Configuration + +These variables configure the CWMS Data API (Java) authorization behavior. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `cwms.dataapi.access.management.enabled` | No | `false` | Enable access management filtering in the Java API | + +### Enabling Access Management + +The Java API ignores authorization headers by default. To enable filtering: + +```bash +# Environment variable +export cwms.dataapi.access.management.enabled=true + +# Or system property +java -Dcwms.dataapi.access.management.enabled=true -jar cwms-data-api.jar + +# Or in docker-compose +environment: + - cwms.dataapi.access.management.enabled=true +``` + +Priority order: System Property > Environment Variable > Default (`false`) + +When disabled, the `AuthorizationContextHelper` and `AuthorizationFilterHelper` classes return no-op values, allowing the API to function without the authorization proxy. + +## Proxy Configuration + +The following variables configure the Authorization Proxy (TypeScript). + +## Server Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PORT` | No | `3001` | HTTP server port | +| `HOST` | No | `0.0.0.0` | HTTP server bind address | +| `LOG_LEVEL` | No | `info` | Logging level: `trace`, `debug`, `info`, `warn`, `error`, `fatal` | +| `NODE_ENV` | No | - | Environment mode: `development`, `production` | + +## CWMS Data API Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CWMS_API_URL` | Yes | `http://localhost:7001/cwms-data` | Base URL of the downstream CWMS Data API | +| `CWMS_API_TIMEOUT` | No | `30000` | Request timeout in milliseconds for downstream API calls | +| `CWMS_API_KEY` | No | - | API key for authenticating proxy requests to CWMS Data API | + +## OPA Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `OPA_URL` | No | `http://localhost:8181` | Open Policy Agent server URL | +| `OPA_POLICY_PATH` | No | `/v1/data/cwms/authorize` | OPA policy evaluation endpoint path | +| `OPA_WHITELIST_ENDPOINTS` | No | `["/cwms-data/timeseries","/cwms-data/offices"]` | JSON array of endpoint prefixes requiring OPA authorization | + +### OPA Whitelist Configuration + +The whitelist determines which endpoints go through OPA policy evaluation. Endpoints not in the whitelist bypass authorization and are proxied directly. + +```bash +# Single endpoint +OPA_WHITELIST_ENDPOINTS='["/cwms-data/timeseries"]' + +# Multiple endpoints +OPA_WHITELIST_ENDPOINTS='["/cwms-data/timeseries","/cwms-data/offices","/cwms-data/locations"]' + +# All endpoints (use with caution) +OPA_WHITELIST_ENDPOINTS='["/cwms-data"]' +``` + +To manage the whitelist using the configuration file: + +```bash +# Edit the whitelist file +vi opa-whitelist.json + +# Load into environment +./scripts/load-whitelist.sh + +# Restart the proxy +podman compose -f docker-compose.podman.yml down authorizer-proxy +podman compose -f docker-compose.podman.yml up -d authorizer-proxy +``` + +## Redis Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `REDIS_URL` | No | `redis://localhost:6379` | Redis connection URL for user context caching | + +### Redis URL Format + +``` +redis://[[username][:password]@][host][:port][/db-number] +``` + +Examples: + +```bash +# Local development +REDIS_URL=redis://localhost:6379 + +# With authentication +REDIS_URL=redis://user:password@redis.example.com:6379 + +# With database selection +REDIS_URL=redis://localhost:6379/1 +``` + +## Cache Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CACHE_TTL_SECONDS` | No | `300` | Time-to-live for cached items in seconds (5 minutes default) | +| `CACHE_MAX_SIZE` | No | `1000` | Maximum number of items in the in-memory cache | + +The proxy uses a two-tier caching strategy: +1. In-memory cache for fast access (configured by these variables) +2. Redis for distributed caching across multiple proxy instances + +## Authorization Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `BYPASS_AUTH` | No | `false` | Skip authorization checks when `true` (development only) | + +Setting `BYPASS_AUTH=true` disables authorization checks. This should only be used for local development and testing. + +## Keycloak Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `KEYCLOAK_URL` | No | - | Keycloak server base URL | +| `KEYCLOAK_ADMIN_USER` | No | - | Keycloak admin username for management operations | +| `KEYCLOAK_ADMIN_PASSWORD` | No | - | Keycloak admin password | + +## Docker Compose Port Mappings + +These variables are used by docker-compose for port mapping: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MANAGEMENT_UI_PORT` | `4200` | Management UI external port | +| `MANAGEMENT_API_PORT` | `3002` | Management API external port | +| `AUTHORIZER_PROXY_PORT` | `3001` | Authorization Proxy external port | +| `REDIS_PORT` | `6379` | Redis external port | +| `OPA_PORT` | `8181` | OPA external port | +| `NETWORK_NAME` | `cwmsdb_net` | Docker network name | + +## Example Configuration File + +```bash +# Server Configuration +NODE_ENV=development +PORT=3001 +HOST=0.0.0.0 +LOG_LEVEL=debug + +# CWMS Data API Configuration +CWMS_API_URL=http://data-api:7000/cwms-data +CWMS_API_TIMEOUT=30000 +CWMS_API_KEY= + +# OPA Configuration +OPA_URL=http://opa:8181 +OPA_POLICY_PATH=/v1/data/cwms/authz/allow +OPA_WHITELIST_ENDPOINTS=["/cwms-data/timeseries","/cwms-data/offices"] + +# Redis Configuration +REDIS_URL=redis://redis:6379 + +# Cache Configuration +CACHE_TTL_SECONDS=300 +CACHE_MAX_SIZE=1000 + +# Authorization +BYPASS_AUTH=false + +# Keycloak Configuration +KEYCLOAK_URL=http://auth:8080/auth +KEYCLOAK_ADMIN_USER=admin +KEYCLOAK_ADMIN_PASSWORD=admin +``` + +## Production Recommendations + +| Variable | Recommendation | +|----------|----------------| +| `LOG_LEVEL` | Set to `info` or `warn` to reduce log volume | +| `BYPASS_AUTH` | Must be `false` in production | +| `CACHE_TTL_SECONDS` | Increase to `1800` (30 minutes) for better performance | +| `CWMS_API_KEY` | Generate and set a secure API key | +| `KEYCLOAK_ADMIN_PASSWORD` | Use a strong, unique password | diff --git a/docs/source/access-management/configuration/index.md b/docs/source/access-management/configuration/index.md new file mode 100644 index 000000000..d45c70908 --- /dev/null +++ b/docs/source/access-management/configuration/index.md @@ -0,0 +1,99 @@ +# Configuration Overview + +The CWMS Authorization Proxy uses environment variables for all configuration. This approach enables flexible deployment across development, staging, and production environments without code changes. + +## Configuration System + +The proxy uses [@fastify/env](https://github.com/fastify/fastify-env) to load and validate environment variables at startup. Configuration is validated against a JSON schema, ensuring required values are present and types are correct before the server starts. + +```mermaid +flowchart LR + envFile[.env file] --> fastifyEnv[fastify-env] + environment[Environment] --> fastifyEnv + fastifyEnv --> validatedConfig[Validated Config] + validatedConfig --> application[Application] +``` + +## Configuration Categories + +| Category | Purpose | Key Variables | +|----------|---------|---------------| +| Server | HTTP server settings | `PORT`, `HOST`, `LOG_LEVEL` | +| CWMS API | Downstream API connection | `CWMS_API_URL`, `CWMS_API_TIMEOUT`, `CWMS_API_KEY` | +| OPA | Policy engine integration | `OPA_URL`, `OPA_POLICY_PATH`, `OPA_WHITELIST_ENDPOINTS` | +| Redis | User context caching | `REDIS_URL` | +| Cache | In-memory cache settings | `CACHE_TTL_SECONDS`, `CACHE_MAX_SIZE` | +| Authorization | Auth behavior control | `BYPASS_AUTH` | + +## Loading Configuration + +Configuration loads from two sources, with environment variables taking precedence: + +1. `.env` file in the application root (loaded via dotenv) +2. Process environment variables + +### Development Setup + +```bash +# Copy example configuration +cp .env.example .env + +# Edit with your local settings +vi .env +``` + +### Container Deployment + +For container deployments, pass environment variables directly: + +```bash +podman run -d \ + -e PORT=3001 \ + -e CWMS_API_URL=http://data-api:7000/cwms-data \ + -e OPA_URL=http://opa:8181 \ + -e REDIS_URL=redis://redis:6379 \ + cwms-authorizer-proxy:local-dev +``` + +Or use the docker-compose file which references the `.env` file: + +```bash +podman compose -f docker-compose.podman.yml up -d authorizer-proxy +``` + +## Applying Configuration Changes + +Configuration is read at startup. To apply changes: + +### Development Mode + +Restart the development server: + +```bash +pnpm nx serve authorizer-proxy +``` + +### Container Mode + +Recreate the container (restart alone does not reload environment variables): + +```bash +podman compose -f docker-compose.podman.yml down authorizer-proxy +podman compose -f docker-compose.podman.yml up -d authorizer-proxy +``` + +## Validation + +The proxy validates all configuration at startup. If required variables are missing or invalid, the server will fail to start with a descriptive error message. + +Required variables: +- `PORT` - Server port (has default) +- `CWMS_API_URL` - Downstream CWMS Data API URL (required, no default in production) + +## Related Documentation + +```{toctree} +:maxdepth: 1 + +environment-variables +``` diff --git a/docs/source/access-management/filtering/classification.md b/docs/source/access-management/filtering/classification.md new file mode 100644 index 000000000..8fb9698dd --- /dev/null +++ b/docs/source/access-management/filtering/classification.md @@ -0,0 +1,187 @@ +# Data Classification Filtering + +Data classification filtering restricts access based on the sensitivity level of data. Each data record can have a classification level, and users can only access data that matches their allowed classifications. + +## Classification Levels + +CWMS supports multiple classification levels for data: + +| Level | Description | +|-------|-------------| +| `public` | Accessible to all authenticated users | +| `internal` | Restricted to internal USACE users | +| `restricted` | Limited to specific authorized personnel | +| `confidential` | Highest sensitivity, very limited access | + +## Constraint Format + +The authorization proxy includes allowed classifications in the constraints: + +```json +{ + "constraints": { + "data_classification": ["public", "internal"] + } +} +``` + +Users with this constraint can access data classified as either "public" or "internal" but not "restricted" or "confidential". + +## Filter Behavior + +```mermaid +flowchart TD + Start[getClassificationFilter called] --> Check{data_classification exists?} + Check -->|No| NoFilter[Return noCondition] + Check -->|Yes| Empty{Array empty?} + Empty -->|Yes| Deny[Return falseCondition] + Empty -->|No| Apply[Return classification IN allowed OR classification IS NULL] +``` + +## Implementation + +The `getClassificationFilter` method generates the appropriate JOOQ condition: + +```java +public Condition getClassificationFilter(Field classificationField) { + if (constraints == null || !constraints.has("data_classification")) { + return DSL.noCondition(); + } + + JsonNode classificationNode = constraints.get("data_classification"); + List allowedClassifications = new ArrayList<>(); + + if (classificationNode.isArray()) { + for (JsonNode classification : classificationNode) { + allowedClassifications.add(classification.asText()); + } + } + + if (allowedClassifications.isEmpty()) { + return DSL.falseCondition(); + } + + // Allow matching classifications OR null (unclassified data) + return DSL.or( + classificationField.in(allowedClassifications), + classificationField.isNull() + ); +} +``` + +## Handling Unclassified Data + +The filter explicitly allows records where the classification field is `NULL`. This ensures that unclassified or legacy data remains accessible to users who have at least one classification level authorized. + +```java +return DSL.or( + classificationField.in(allowedClassifications), + classificationField.isNull() +); +``` + +## Scenarios + +### Standard User Access + +User with `data_classification: ["public", "internal"]`: + +```sql +SELECT * FROM at_cwms_ts_id +WHERE (data_classification IN ('public', 'internal') + OR data_classification IS NULL) +``` + +### Elevated Access + +User with all classification levels: + +```json +{ + "constraints": { + "data_classification": ["public", "internal", "restricted", "confidential"] + } +} +``` + +```sql +SELECT * FROM at_cwms_ts_id +WHERE (data_classification IN ('public', 'internal', 'restricted', 'confidential') + OR data_classification IS NULL) +``` + +### No Classification Access + +If the `data_classification` array is empty, all access is denied: + +```sql +SELECT * FROM at_cwms_ts_id +WHERE 1 = 0 +``` + +### No Constraint Defined + +If no `data_classification` constraint exists in the header, no filtering is applied: + +```sql +SELECT * FROM at_cwms_ts_id +-- No classification condition +``` + +## Usage Example + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +// Get classification filter +Condition classFilter = filterHelper.getClassificationFilter( + TIMESERIES.DATA_CLASSIFICATION +); + +// Apply to query +SelectQuery query = dsl.selectFrom(TIMESERIES) + .where(classFilter) + .getQuery(); +``` + +## Combined Filtering + +In practice, classification filtering is combined with other filter types using the `getAllFilters` method: + +```java +public Condition getAllFilters( + Field officeField, + Field timestampField, + Field classificationField, + String requestedOffice, + Timestamp userRequestedBeginTime) { + + if (constraints == null) { + return DSL.noCondition(); + } + + Condition officeFilter = getOfficeFilter(officeField, requestedOffice); + Condition embargoFilter = getEmbargoFilter(timestampField, officeField, requestedOffice); + Condition timeWindowFilter = getTimeWindowFilter(timestampField, userRequestedBeginTime); + Condition classificationFilter = classificationField != null + ? getClassificationFilter(classificationField) + : DSL.noCondition(); + + return DSL.and(officeFilter, embargoFilter, timeWindowFilter, classificationFilter); +} +``` + +This ensures that a record must pass all filters to be returned. + +## Generated SQL Example + +For a user with office restrictions, embargo rules, and classification limits: + +```sql +SELECT * FROM at_cwms_ts_id +WHERE office_id IN ('SWT', 'SPK') + AND version_date < TIMESTAMP '2024-01-13 10:00:00' + AND (data_classification IN ('public', 'internal') + OR data_classification IS NULL) +``` + diff --git a/docs/source/access-management/filtering/embargo-rules.md b/docs/source/access-management/filtering/embargo-rules.md new file mode 100644 index 000000000..c5db95073 --- /dev/null +++ b/docs/source/access-management/filtering/embargo-rules.md @@ -0,0 +1,279 @@ +# Embargo Rules + +Embargo rules restrict access to recent data. Data that is newer than the embargo period is considered "embargoed" and will not be returned to users who are not exempt. This is commonly used to give data owners time to review and validate data before it becomes publicly accessible. + +## Embargo Concept + +The embargo period defines how old data must be before a user can access it. For example, a 168-hour (7-day) embargo means users can only see data that is at least 7 days old. + +```mermaid +flowchart LR + subgraph Embargoed + Recent[Recent Data
Last 7 days] + end + subgraph Accessible + Historical[Historical Data
Older than 7 days] + end + Recent -.->|Blocked| User + Historical -->|Allowed| User +``` + +## Constraint Format + +Embargo rules appear in the `x-cwms-auth-context` header in two forms: + +### Office-Based Embargo + +```json +{ + "constraints": { + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false + } +} +``` + +| Field | Description | +|-------|-------------| +| `embargo_rules.` | Hours of embargo for specific office | +| `embargo_rules.default` | Default embargo when office not specified | +| `embargo_exempt` | If true, user bypasses all embargo rules | + +### Time Series Group Embargo + +```json +{ + "constraints": { + "ts_group_embargo": { + "Streamflow": 72, + "Stage": 24, + "Precipitation": 0 + }, + "embargo_exempt": false + } +} +``` + +Time series group embargo allows different embargo periods based on data type rather than office. + +## Implementation + +### Office-Based Embargo Filter + +The `getEmbargoFilter` method applies office-based embargo rules: + +```java +public Condition getEmbargoFilter( + Field timestampField, + Field officeField, + String requestedOffice) { + + if (constraints == null) { + return DSL.noCondition(); + } + + // Check exemption first + boolean embargoExempt = constraints.has("embargo_exempt") && + constraints.get("embargo_exempt").asBoolean(); + if (embargoExempt) { + return DSL.noCondition(); + } + + JsonNode embargoRulesNode = constraints.get("embargo_rules"); + if (embargoRulesNode == null || embargoRulesNode.isNull()) { + return DSL.noCondition(); + } + + // Apply office-specific embargo + if (requestedOffice != null && embargoRulesNode.has(requestedOffice)) { + int embargoHours = embargoRulesNode.get(requestedOffice).asInt(); + Timestamp cutoff = Timestamp.from( + Instant.now().minus(embargoHours, ChronoUnit.HOURS) + ); + return timestampField.lessThan(cutoff); + } + + // Fall back to default embargo + if (embargoRulesNode.has("default")) { + int defaultHours = embargoRulesNode.get("default").asInt(); + Timestamp defaultCutoff = Timestamp.from( + Instant.now().minus(defaultHours, ChronoUnit.HOURS) + ); + return timestampField.lessThan(defaultCutoff); + } + + return DSL.noCondition(); +} +``` + +### Time Series Group Embargo Filter + +The `getTsGroupEmbargoFilter` method applies embargo based on time series group: + +```java +public Condition getTsGroupEmbargoFilter( + Field timestampField, + String tsGroupId) { + + if (constraints == null) { + return DSL.noCondition(); + } + + boolean embargoExempt = constraints.has("embargo_exempt") && + constraints.get("embargo_exempt").asBoolean(); + if (embargoExempt) { + return DSL.noCondition(); + } + + JsonNode tsGroupEmbargoNode = constraints.get("ts_group_embargo"); + if (tsGroupEmbargoNode == null || tsGroupEmbargoNode.isNull()) { + return DSL.noCondition(); + } + + if (tsGroupId != null && tsGroupEmbargoNode.has(tsGroupId)) { + int embargoHours = tsGroupEmbargoNode.get(tsGroupId).asInt(); + if (embargoHours == 0) { + return DSL.noCondition(); // Zero means no embargo + } + Timestamp cutoff = Timestamp.from( + Instant.now().minus(embargoHours, ChronoUnit.HOURS) + ); + return timestampField.lessThan(cutoff); + } + + // Default to 7 days for unknown groups + int defaultHours = 168; + Timestamp defaultCutoff = Timestamp.from( + Instant.now().minus(defaultHours, ChronoUnit.HOURS) + ); + return timestampField.lessThan(defaultCutoff); +} +``` + +## Filter Behavior + +```mermaid +flowchart TD + Start[getEmbargoFilter called] --> Exempt{embargo_exempt?} + Exempt -->|Yes| NoFilter[Return noCondition] + Exempt -->|No| Rules{embargo_rules exists?} + Rules -->|No| NoFilter + Rules -->|Yes| Office{Office specified?} + Office -->|Yes| OfficeRule{Office rule exists?} + OfficeRule -->|Yes| ApplyOffice[Apply office embargo] + OfficeRule -->|No| Default{default rule exists?} + Office -->|No| Default + Default -->|Yes| ApplyDefault[Apply default embargo] + Default -->|No| NoFilter +``` + +## Embargo Exemption + +Certain user personas are exempt from embargo rules. In OPA policy: + +```rego +embargo_exempt_personas := ["data_manager", "water_manager", "system_admin", "hec_employee"] + +user_embargo_exempt(user) if { + user.persona in embargo_exempt_personas +} +``` + +When `embargo_exempt: true` is set in constraints, no embargo filtering is applied. + +## Generated SQL Examples + +For a user with 168-hour embargo on SPK: + +```sql +SELECT * FROM at_cwms_ts_id +WHERE version_date < TIMESTAMP '2024-01-13 10:00:00' +``` + +For an exempt user: +```sql +SELECT * FROM at_cwms_ts_id +-- No embargo condition applied +``` + +## Time Window Restrictions + +Time window restrictions are the inverse of embargo rules. Instead of blocking recent data, they limit how far back a user can query historical data. This is useful for operational users who only need current data. + +### Constraint Format + +```json +{ + "constraints": { + "time_window": { + "restrict_hours": 8 + } + } +} +``` + +### Implementation + +```java +public Condition getTimeWindowFilter( + Field timestampField, + Timestamp userRequestedBeginTime) { + + if (constraints == null || !constraints.has("time_window")) { + return DSL.noCondition(); + } + + JsonNode timeWindowNode = constraints.get("time_window"); + if (timeWindowNode.isNull() || !timeWindowNode.has("restrict_hours")) { + return DSL.noCondition(); + } + + int restrictHours = timeWindowNode.get("restrict_hours").asInt(); + Timestamp cutoffTime = Timestamp.from( + Instant.now().minus(restrictHours, ChronoUnit.HOURS) + ); + + // If user requested older data, enforce cutoff + if (userRequestedBeginTime == null || userRequestedBeginTime.before(cutoffTime)) { + return timestampField.greaterOrEqual(cutoffTime); + } + + return timestampField.greaterOrEqual(userRequestedBeginTime); +} +``` + +### Comparison: Embargo vs Time Window + +| Rule Type | Blocks | Allows | Use Case | +|-----------|--------|--------|----------| +| Embargo | Recent data (newer than X hours) | Historical data | Data validation period | +| Time Window | Historical data (older than X hours) | Recent data | Operational dashboards | + +## Usage Example + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +// Get embargo filter for office-based data +Condition embargoFilter = filterHelper.getEmbargoFilter( + TIMESERIES.VERSION_DATE, + TIMESERIES.OFFICE_ID, + "SPK" +); + +// Get time window filter +Condition timeWindowFilter = filterHelper.getTimeWindowFilter( + TIMESERIES.VERSION_DATE, + userRequestedBeginTime +); + +// Combine filters +SelectQuery query = dsl.selectFrom(TIMESERIES) + .where(DSL.and(embargoFilter, timeWindowFilter)) + .getQuery(); +``` + diff --git a/docs/source/access-management/filtering/index.md b/docs/source/access-management/filtering/index.md new file mode 100644 index 000000000..9ff9330c6 --- /dev/null +++ b/docs/source/access-management/filtering/index.md @@ -0,0 +1,112 @@ +# Data Filtering Overview + +The CWMS Access Management system enforces data access controls through database-level filtering. The authorization proxy passes filtering constraints to the Java API via the `x-cwms-auth-context` header, and the API applies these constraints directly to database queries using JOOQ conditions. + +## Architecture + +```mermaid +flowchart LR + Client --> Proxy[Authorization Proxy] + Proxy --> OPA[OPA Policy Engine] + OPA --> Proxy + Proxy --> API[Java API] + API --> Helper[AuthorizationFilterHelper] + Helper --> DB[(Oracle Database)] +``` + +The key principle is that all filtering happens at the database level. The authorization proxy determines what constraints apply to a user, but the Java API enforces them by modifying SQL queries. This ensures data never leaves the database unless the user is authorized to see it. + +## Filtering Mechanism + +The `AuthorizationFilterHelper` class parses the `x-cwms-auth-context` header and generates JOOQ `Condition` objects that are applied to WHERE clauses: + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +Condition allFilters = filterHelper.getAllFilters( + OFFICE_ID, // office field + VERSION_DATE, // timestamp field + DATA_CLASSIFICATION, // classification field + requestedOffice, // user-requested office (optional) + userRequestedBeginTime // user-requested start time (optional) +); + +SelectQuery query = dsl.selectFrom(TABLE) + .where(allFilters) + .getQuery(); +``` + +## Filter Types + +| Filter Type | Purpose | Constraint Field | +|-------------|---------|------------------| +| [Office Filtering](office-filtering.md) | Restrict access to specific offices | `allowed_offices` | +| [Embargo Rules](embargo-rules.md) | Restrict access to recent data | `embargo_rules`, `ts_group_embargo` | +| [Time Window](embargo-rules.md) | Limit historical data access | `time_window` | +| [Data Classification](classification.md) | Control access by sensitivity level | `data_classification` | + +## Authorization Context Header + +The proxy sends filtering constraints in the `x-cwms-auth-context` header as JSON: + +```json +{ + "policy": { + "allow": true, + "decision_id": "proxy-abc123" + }, + "user": { + "id": "m5hectest", + "username": "m5hectest", + "roles": ["cwms_user"], + "offices": ["SWT"], + "primary_office": "SWT" + }, + "constraints": { + "allowed_offices": ["SWT", "SPK"], + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"] + } +} +``` + +## Filter Combination + +When multiple filters apply, they are combined with AND logic: + +```java +return DSL.and(officeFilter, embargoFilter, timeWindowFilter, classificationFilter); +``` + +This means a record must pass all filters to be returned. For example, a user with office restrictions and embargo rules will only see data that: + +1. Belongs to one of their allowed offices +2. Is older than the embargo period +3. Falls within their time window +4. Matches their allowed classifications + +## Disabled Mode + +When access management is disabled (via configuration), the `AuthorizationFilterHelper` returns `DSL.noCondition()` for all filters, effectively allowing unrestricted access. This is determined by checking `AuthorizationContextHelper.isEnabled()` at construction time. + +## Related Documentation + +- [Office-Based Filtering](office-filtering.md) +- [Embargo Rules](embargo-rules.md) +- [Data Classification](classification.md) + +```{toctree} +:maxdepth: 2 + +office-filtering +embargo-rules +classification +``` diff --git a/docs/source/access-management/filtering/office-filtering.md b/docs/source/access-management/filtering/office-filtering.md new file mode 100644 index 000000000..47b38f9a5 --- /dev/null +++ b/docs/source/access-management/filtering/office-filtering.md @@ -0,0 +1,162 @@ +# Office-Based Filtering + +Office-based filtering restricts data access based on the user's authorized offices. Each CWMS user is associated with one or more offices, and the filtering ensures they can only query data belonging to those offices. + +## How It Works + +The authorization proxy evaluates the user's office permissions and includes an `allowed_offices` array in the constraints: + +```json +{ + "constraints": { + "allowed_offices": ["SWT", "SPK", "NWD"] + } +} +``` + +The Java API uses this array to generate a JOOQ condition that filters query results. + +## Filter Behavior + +```mermaid +flowchart TD + Start[getOfficeFilter called] --> Check{allowed_offices exists?} + Check -->|No| NoFilter[Return noCondition] + Check -->|Yes| Wildcard{Contains wildcard?} + Wildcard -->|Yes| NoFilter + Wildcard -->|No| Empty{Array empty?} + Empty -->|Yes| Deny[Return falseCondition] + Empty -->|No| Requested{Office requested?} + Requested -->|Yes| Authorized{User authorized?} + Authorized -->|Yes| Single[Return office = requested] + Authorized -->|No| Deny + Requested -->|No| Multiple[Return office IN allowed] +``` + +## Implementation + +The `getOfficeFilter` method in `AuthorizationFilterHelper` handles all office filtering scenarios: + +```java +public Condition getOfficeFilter(Field officeField, String requestedOffice) { + if (constraints == null || !constraints.has("allowed_offices")) { + return DSL.noCondition(); + } + + JsonNode allowedOfficesNode = constraints.get("allowed_offices"); + List allowedOffices = new ArrayList<>(); + + if (allowedOfficesNode.isArray()) { + for (JsonNode office : allowedOfficesNode) { + allowedOffices.add(office.asText()); + } + } + + // Wildcard grants access to all offices + if (allowedOffices.contains("*")) { + return DSL.noCondition(); + } + + // Empty array denies all access + if (allowedOffices.isEmpty()) { + return DSL.falseCondition(); + } + + // Specific office requested - verify authorization + if (requestedOffice != null && !requestedOffice.isEmpty()) { + if (!allowedOffices.contains(requestedOffice)) { + return DSL.falseCondition(); + } + return officeField.eq(requestedOffice); + } + + // No specific office - filter to all allowed + return officeField.in(allowedOffices); +} +``` + +## Scenarios + +### Wildcard Access + +Users with administrative roles may have wildcard access to all offices: + +```json +{ + "constraints": { + "allowed_offices": ["*"] + } +} +``` + +The filter returns `noCondition()`, allowing access to data from any office. + +### Specific Office Request + +When a user requests data from a specific office (via query parameter): + +| User's allowed_offices | Requested office | Result | +|------------------------|------------------|--------| +| `["SWT", "SPK"]` | `SWT` | `office_id = 'SWT'` | +| `["SWT", "SPK"]` | `NWD` | `false` (denied) | +| `["*"]` | `NWD` | No condition (allowed) | + +### Multiple Office Access + +When no specific office is requested, the filter returns data from all allowed offices: + +```sql +WHERE office_id IN ('SWT', 'SPK', 'NWD') +``` + +### No Office Access + +If the `allowed_offices` array is empty, all access is denied: + +```java +if (allowedOffices.isEmpty()) { + return DSL.falseCondition(); +} +``` + +This results in a WHERE clause that always evaluates to false, returning no records. + +## Usage Example + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +// Get the office filter condition +Condition officeFilter = filterHelper.getOfficeFilter( + TIMESERIES.OFFICE_ID, // The office field in the table + requestedOffice // Office from query parameter (may be null) +); + +// Apply to query +SelectQuery query = dsl.selectFrom(TIMESERIES) + .where(officeFilter) + .getQuery(); +``` + +## Generated SQL Examples + +For a user with `allowed_offices: ["SWT", "SPK"]`: + +No specific office requested: +```sql +SELECT * FROM at_cwms_ts_id +WHERE office_id IN ('SWT', 'SPK') +``` + +Specific office requested (authorized): +```sql +SELECT * FROM at_cwms_ts_id +WHERE office_id = 'SWT' +``` + +Specific office requested (unauthorized): +```sql +SELECT * FROM at_cwms_ts_id +WHERE 1 = 0 +``` + diff --git a/docs/source/access-management/header-format/constraints.md b/docs/source/access-management/header-format/constraints.md new file mode 100644 index 000000000..ea699af44 --- /dev/null +++ b/docs/source/access-management/header-format/constraints.md @@ -0,0 +1,228 @@ +# Constraints Schema + +The `constraints` object in the `x-cwms-auth-context` header defines data filtering rules that the Java API applies at the database query level. + +## Schema Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `allowed_offices` | string[] | yes | Office IDs the user can access, or ["*"] for all | +| `embargo_rules` | object | no | Per-office embargo hours, null if no embargo | +| `embargo_exempt` | boolean | yes | Whether user bypasses embargo restrictions | +| `ts_group_embargo` | object | no | Per-time-series-group embargo hours | +| `time_window` | object | no | Restricts access to recent data only | +| `data_classification` | string[] | yes | Classification levels the user can access | + +## Field Definitions + +### allowed_offices + +Array of CWMS office identifiers that the user can access. The Java API filters query results to only include data from these offices. + +| Value | Meaning | +|-------|---------| +| `["SWT", "SPK"]` | User can access SWT and SPK office data | +| `["*"]` | User can access all offices (system admin, automated processor) | +| `[]` | No office access (effectively read-only public data) | + +### embargo_rules + +Object mapping office IDs to embargo periods in hours. Data newer than the embargo period is restricted. A `default` key provides the fallback for offices not explicitly listed. + +```json +{ + "SPK": 168, + "SWT": 72, + "default": 168 +} +``` + +The embargo period is measured from the current time backward. Data with timestamps within the embargo window is filtered out for non-exempt users. In the example above, SPK data less than 168 hours (7 days) old is embargoed. + +Set to `null` when no office-based embargo applies. + +### embargo_exempt + +Boolean flag indicating whether the user bypasses embargo restrictions entirely. Users with certain personas or roles are automatically exempt: + +| Exempt Personas | Exempt Roles | +|-----------------|--------------| +| data_manager | system_admin | +| water_manager | hec_employee | +| system_admin | data_manager | +| | water_manager | + +### ts_group_embargo + +Object mapping time series group IDs to embargo periods in hours. This provides granular embargo control at the time series group level, independent of office-based embargo. + +```json +{ + "Default": 0, + "Sensitive": 168, + "Operational": 24 +} +``` + +Set to `null` when no time-series-group-based embargo applies or when the user has no ts_privileges defined. + +### time_window + +Object restricting access to only recent data. Used for personas like dam_operator who should only see current operational data. + +| Field | Type | Description | +|-------|------|-------------| +| `restrict_hours` | number | Only data from the last N hours is accessible | + +```json +{ + "restrict_hours": 8 +} +``` + +Set to `null` when no time window restriction applies. + +### data_classification + +Array of data classification levels the user can access. Higher privilege users can access more restrictive classifications. + +| Level | Description | +|-------|-------------| +| `public` | Publicly available data | +| `internal` | Internal agency data | +| `restricted` | Restricted access data | +| `sensitive` | Sensitive operational data | + +Classification access by role: + +| User Type | Classifications | +|-----------|-----------------| +| Anonymous | public | +| Authenticated | public, internal | +| data_manager, water_manager | public, internal, restricted, sensitive | +| system_admin, hec_employee | public, internal, restricted, sensitive | + +## Complete Examples + +### Standard Authenticated User + +User with access to their assigned offices, subject to standard embargo rules. + +```json +{ + "allowed_offices": ["SWT"], + "embargo_rules": { + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false, + "ts_group_embargo": null, + "time_window": null, + "data_classification": ["public", "internal"] +} +``` + +### Dam Operator + +Operator restricted to recent operational data from their office. + +```json +{ + "allowed_offices": ["SWT"], + "embargo_rules": null, + "embargo_exempt": true, + "ts_group_embargo": null, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"] +} +``` + +### Water Manager + +Manager with full access to office data, exempt from embargo restrictions. + +```json +{ + "allowed_offices": ["SWT", "SPK"], + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "embargo_exempt": true, + "ts_group_embargo": { + "Default": 0, + "Sensitive": 0 + }, + "time_window": null, + "data_classification": ["public", "internal", "restricted", "sensitive"] +} +``` + +### System Administrator + +Full system access with no restrictions. + +```json +{ + "allowed_offices": ["*"], + "embargo_rules": null, + "embargo_exempt": true, + "ts_group_embargo": null, + "time_window": null, + "data_classification": ["public", "internal", "restricted", "sensitive"] +} +``` + +### Anonymous User + +Public access only, subject to all embargo restrictions. + +```json +{ + "allowed_offices": [], + "embargo_rules": { + "default": 168 + }, + "embargo_exempt": false, + "ts_group_embargo": null, + "time_window": null, + "data_classification": ["public"] +} +``` + +### Partner with Time Series Group Access + +External partner with specific time series group privileges. + +```json +{ + "allowed_offices": ["SPK"], + "embargo_rules": { + "SPK": 168, + "default": 168 + }, + "embargo_exempt": false, + "ts_group_embargo": { + "Default": 72, + "Partner-Shared": 0 + }, + "time_window": null, + "data_classification": ["public", "internal"] +} +``` + +## Java API Implementation + +The `AuthorizationFilterHelper` class processes these constraints and generates JOOQ conditions: + +- `allowed_offices` generates `WHERE office_id IN (...)` conditions +- `embargo_rules` generates `WHERE data_timestamp < SYSDATE - (embargo_hours/24)` conditions +- `ts_group_embargo` generates per-group timestamp filters +- `time_window` generates `WHERE data_timestamp > SYSDATE - (restrict_hours/24)` conditions +- `data_classification` generates `WHERE classification IN (...)` conditions + +When multiple constraints apply, they are combined with AND logic. + diff --git a/docs/source/access-management/header-format/index.md b/docs/source/access-management/header-format/index.md new file mode 100644 index 000000000..89c9fa041 --- /dev/null +++ b/docs/source/access-management/header-format/index.md @@ -0,0 +1,106 @@ +# x-cwms-auth-context Header + +The `x-cwms-auth-context` header carries authorization decisions and user context from the Authorization Proxy to the CWMS Data API. This header enables the Java API to enforce data filtering constraints at the database level without performing its own authorization logic. + +## Overview + +When a request passes through the Authorization Proxy, the proxy: + +1. Extracts the user identity from the JWT Bearer token +2. Queries the CWMS Data API for user context (roles, offices, privileges) +3. Sends the authorization request to OPA for policy evaluation +4. Constructs the `x-cwms-auth-context` header with the decision and constraints +5. Forwards the request to the CWMS Data API with the header attached + +The Java API receives this header and uses `AuthorizationFilterHelper` to apply the constraints as JOOQ `Condition` objects in database queries. + +## Header Structure + +The header value is a JSON-encoded object with the following top-level properties: + +| Property | Type | Description | +|----------|------|-------------| +| `policy` | object | OPA authorization decision with allow/deny result | +| `user` | object | User identity and attributes from CWMS database | +| `constraints` | object | Data filtering rules to apply at query time | +| `context` | object | Additional context passed from OPA decision | +| `timestamp` | string | ISO 8601 timestamp when the header was generated | + +## Complete Example + +```json +{ + "policy": { + "allow": true, + "decision_id": "proxy-1705734521234-abc123def" + }, + "user": { + "id": "m5hectest", + "username": "m5hectest", + "email": "m5hectest@example.com", + "roles": ["cwms_user", "ts_id_creator"], + "offices": ["SWT"], + "primary_office": "SWT", + "persona": "water_manager", + "ts_privileges": [ + { + "ts_group_code": 1, + "ts_group_id": "Default", + "privilege": "read-write", + "embargo_hours": 0 + } + ] + }, + "constraints": { + "allowed_offices": ["SWT", "SPK"], + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false, + "ts_group_embargo": { + "Default": 0, + "Sensitive": 168 + }, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"] + }, + "context": {}, + "timestamp": "2025-01-20T12:15:21.234Z" +} +``` + +## Policy Object + +The `policy` object contains the OPA authorization decision. + +| Field | Type | Description | +|-------|------|-------------| +| `allow` | boolean | Whether the request is authorized | +| `decision_id` | string | Unique identifier for audit logging | + +## Java API Consumption + +The Java API parses this header in `AuthorizationFilterHelper.java` and generates JOOQ conditions for WHERE clauses. The helper extracts constraints and builds filter conditions that are applied to all relevant database queries. + +Key implementation points: + +- The header is only present on whitelisted endpoints that pass through OPA +- Non-whitelisted endpoints bypass the proxy and do not have this header +- The Java API must handle requests both with and without this header +- When present, the constraints in this header take precedence over any default filtering + +## Related Documentation + +- [User Context Schema](user-context.md) - User object field reference +- [Constraints Schema](constraints.md) - Data filtering constraints reference + +```{toctree} +:maxdepth: 2 + +user-context +constraints +``` diff --git a/docs/source/access-management/header-format/user-context.md b/docs/source/access-management/header-format/user-context.md new file mode 100644 index 000000000..39f97907c --- /dev/null +++ b/docs/source/access-management/header-format/user-context.md @@ -0,0 +1,121 @@ +# User Context Schema + +The `user` object in the `x-cwms-auth-context` header contains identity and attributes retrieved from the CWMS database and the user's JWT token. + +## Schema Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | yes | Unique user identifier, typically matches username | +| `username` | string | yes | CWMS username from at_sec_cwms_users table | +| `email` | string | no | Email address from JWT claims | +| `roles` | string[] | yes | User groups from av_sec_users view | +| `offices` | string[] | yes | Office IDs the user belongs to | +| `primary_office` | string | no | Default office for the user | +| `persona` | string | no | User persona for role-based behavior | +| `region` | string | no | Geographic region assignment | +| `timezone` | string | no | User's preferred timezone | +| `shift_start` | number | no | Shift start hour (0-23) for time-based access | +| `shift_end` | number | no | Shift end hour (0-23) for time-based access | +| `authenticated` | boolean | no | Whether the user provided valid credentials | +| `auth_method` | string | no | Authentication method used (jwt, api_key, etc.) | +| `allowed_parameters` | string[] | no | Specific parameter IDs the user can access | +| `partnership_expiry` | string | no | ISO 8601 date when partnership access expires | +| `ts_privileges` | TsGroupPrivilege[] | no | Time series group access privileges | +| `attributes` | object | no | Additional custom attributes | + +## TsGroupPrivilege Schema + +The `ts_privileges` array contains per-group access settings. + +| Field | Type | Description | +|-------|------|-------------| +| `ts_group_code` | number | Numeric code for the time series group | +| `ts_group_id` | string | String identifier for the time series group | +| `privilege` | string | Access level: "read", "write", "read-write", or "none" | +| `embargo_hours` | number | Hours of embargo restriction for this group | + +## Persona Values + +The `persona` field controls behavior-specific access patterns. + +| Persona | Description | +|---------|-------------| +| `dam_operator` | Restricted to recent data (time_window applies) | +| `water_manager` | Full access to office data, embargo exempt | +| `data_manager` | Administrative access, embargo exempt | +| `automated_processor` | System access to all offices | +| `system_admin` | Full system access | + +## Example: Authenticated User + +```json +{ + "id": "m5hectest", + "username": "m5hectest", + "email": "m5hectest@example.com", + "roles": ["cwms_user", "ts_id_creator", "all_users"], + "offices": ["SWT"], + "primary_office": "SWT", + "persona": "water_manager", + "authenticated": true, + "auth_method": "jwt", + "ts_privileges": [ + { + "ts_group_code": 1, + "ts_group_id": "Default", + "privilege": "read-write", + "embargo_hours": 0 + }, + { + "ts_group_code": 2, + "ts_group_id": "Sensitive", + "privilege": "read", + "embargo_hours": 168 + } + ] +} +``` + +## Example: Anonymous User + +```json +{ + "id": "anonymous", + "username": "anonymous", + "email": "anonymous@example.com", + "roles": [], + "offices": [], + "authenticated": false +} +``` + +## Example: Dam Operator with Shift Hours + +```json +{ + "id": "operator123", + "username": "operator123", + "roles": ["cwms_user", "dam_operator"], + "offices": ["SWT"], + "primary_office": "SWT", + "persona": "dam_operator", + "timezone": "America/Chicago", + "shift_start": 6, + "shift_end": 18, + "authenticated": true +} +``` + +## Data Sources + +User context fields are populated from multiple sources: + +| Source | Fields | +|--------|--------| +| CWMS at_sec_cwms_users | username, offices, primary_office | +| CWMS av_sec_users | roles | +| JWT token claims | id (sub), email, preferred_username | +| OPA policy decision | persona (may be assigned by policy) | +| User configuration | timezone, shift_start, shift_end, region | + diff --git a/docs/source/access-management/index.md b/docs/source/access-management/index.md new file mode 100644 index 000000000..66e89afc3 --- /dev/null +++ b/docs/source/access-management/index.md @@ -0,0 +1,62 @@ +# Access Management + +The CWMS Access Management system provides fine-grained authorization for the CWMS Data API. It uses a transparent proxy pattern combined with Open Policy Agent (OPA) to evaluate access policies before requests reach the backend API. + +## Overview + +The system implements a defense-in-depth security model with three distinct layers: + +| Layer | Component | Responsibility | +|-------|-----------|----------------| +| Authentication | Keycloak | JWT token issuance and validation | +| Authorization | OPA | Policy-based access control decisions | +| Data Filtering | CWMS Data API | Server-side constraint enforcement at SQL level | + +The authorization proxy sits between clients and the CWMS Data API, intercepting requests to evaluate policies and inject authorization context. The backend API applies filtering constraints at the database level based on this context, ensuring that users only see data they are permitted to access. + +## Key Principles + +The architecture follows several guiding principles: + +- All authorization decisions are made by OPA based on user context and configured policies +- The Java API does not make authorization decisions; it only applies constraints passed via headers +- User context is cached in Redis to reduce database load and improve response times +- Server-side filtering ensures data security regardless of client behavior + +## Documentation + +```{toctree} +:maxdepth: 2 + +architecture/index +configuration/index +filtering/index +header-format/index +integration/index +management/index +performance/index +policies/index +proxy-api/index +``` + +## Service Endpoints + +| Service | Port | Purpose | +|---------|------|---------| +| Authorization Proxy | 3001 | Request interception and policy evaluation | +| OPA | 8181 | Policy engine for authorization decisions | +| Redis | 6379 | User context caching | +| CWMS Data API | 7001 | Backend data API with SQL-level filtering | +| Keycloak | 8080 | Identity provider and JWT issuer | +| Management UI | 4200 | Web interface for policy management | + +## Technology Stack + +| Component | Technology | +|-----------|------------| +| Authorization Proxy | Node.js 24, TypeScript, Fastify | +| Policy Engine | OPA 0.68.0 | +| Cache | Redis 7.x | +| Data API | Java 11 | +| Database | Oracle 23c Free | +| Authentication | Keycloak 19.0.1 | diff --git a/docs/source/access-management/integration/authorization-context-helper.md b/docs/source/access-management/integration/authorization-context-helper.md new file mode 100644 index 000000000..6d04c5523 --- /dev/null +++ b/docs/source/access-management/integration/authorization-context-helper.md @@ -0,0 +1,348 @@ +# AuthorizationContextHelper + +The `AuthorizationContextHelper` class parses the `x-cwms-auth-context` header and provides methods to access user information and constraints. + +**Package**: `cwms.cda.helpers` + +**Source**: `cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationContextHelper.java` + +## Purpose + +This helper class serves as the bridge between the Authorization Proxy and the Java API. It: + +- Parses the JSON authorization context from the request header +- Extracts user identity information (id, username, email) +- Provides access to user roles and office assignments +- Exposes constraint values for filtering +- Respects the enabled/disabled configuration + +## Configuration + +The helper checks the `cwms.dataapi.access.management.enabled` property at class load time. When disabled, all methods return empty values regardless of header content. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `cwms.dataapi.access.management.enabled` | boolean | false | Enable authorization header processing | + +The property can be set via environment variable or system property: + +```bash +# Environment variable +export cwms.dataapi.access.management.enabled=true + +# System property +java -Dcwms.dataapi.access.management.enabled=true ... +``` + +## Constructor + +```java +public AuthorizationContextHelper(Context ctx) +``` + +Creates a new helper instance by parsing the `x-cwms-auth-context` header from the Javalin context. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ctx` | `io.javalin.http.Context` | The Javalin request context | + +**Behavior**: +- If authorization is disabled, all internal maps are empty +- If header is missing or invalid, all internal maps are empty +- Invalid JSON is logged as a warning and treated as missing + +## Static Methods + +### isEnabled + +```java +public static boolean isEnabled() +``` + +Returns whether authorization mode is enabled. + +**Returns**: `true` if `cwms.dataapi.access.management.enabled` is set to `true` + +## User Context Methods + +### getUserId + +```java +public String getUserId() +``` + +Returns the user's unique identifier from the `user.id` field. + +**Returns**: User ID string, or `null` if not present + +### getUsername + +```java +public String getUsername() +``` + +Returns the user's username from the `user.username` field. + +**Returns**: Username string, or `null` if not present + +### getEmail + +```java +public String getEmail() +``` + +Returns the user's email address from the `user.email` field. + +**Returns**: Email string, or `null` if not present + +### getRoles + +```java +public List getRoles() +``` + +Returns the list of roles assigned to the user from the `user.roles` array. + +**Returns**: List of role names, or empty list if not present + +### getOffices + +```java +public List getOffices() +``` + +Returns the list of offices the user has access to from the `user.offices` array. + +**Returns**: List of office codes, or empty list if not present + +### getPrimaryOffice + +```java +public String getPrimaryOffice() +``` + +Returns the user's primary office from the `user.primary_office` field. + +**Returns**: Office code string, or `null` if not present + +### getPersona + +```java +public String getPersona() +``` + +Returns the user's active persona from the `user.persona` field. + +**Returns**: Persona name, or `null` if not present + +### getRegion + +```java +public String getRegion() +``` + +Returns the user's region from the `user.region` field. + +**Returns**: Region name, or `null` if not present + +## Constraint Methods + +### getAllowedOfficesConstraint + +```java +public String getAllowedOfficesConstraint() +``` + +Returns the allowed offices constraint value from `constraints.allowed_offices`. + +**Returns**: Constraint value string, or `null` if not present + +### isEmbargoExempt + +```java +public boolean isEmbargoExempt() +``` + +Returns whether the user is exempt from embargo rules based on `constraints.embargo_exempt`. + +**Returns**: `true` if user is embargo exempt, `false` otherwise + +### getTimezone + +```java +public String getTimezone() +``` + +Returns the user's timezone preference from `constraints.timezone`. + +**Returns**: Timezone string, or `null` if not present + +## Utility Methods + +### hasRole + +```java +public boolean hasRole(String role) +``` + +Checks if the user has a specific role. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `role` | `String` | The role name to check | + +**Returns**: `true` if the user has the specified role + +### hasOfficeAccess + +```java +public boolean hasOfficeAccess(String office) +``` + +Checks if the user has access to a specific office. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `office` | `String` | The office code to check | + +**Returns**: `true` if user has access, or if no authorization header is present + +### buildOfficeFilter + +```java +public String buildOfficeFilter() +``` + +Builds a comma-separated string of allowed offices for use in queries. + +**Returns**: +- `null` if no authorization header is present +- `null` if allowed offices constraint is `*` (all offices) +- Comma-separated office codes otherwise + +### isAuthorizationHeaderPresent + +```java +public boolean isAuthorizationHeaderPresent() +``` + +Checks if a valid authorization context header was present in the request. + +**Returns**: `true` if the header was present and successfully parsed + +### getFullContext + +```java +public Map getFullContext() +``` + +Returns an unmodifiable view of the complete parsed authorization context. + +**Returns**: Immutable map containing the full context, or empty map if not present + +## Usage Examples + +### Basic User Information + +```java +AuthorizationContextHelper auth = new AuthorizationContextHelper(ctx); + +if (auth.isAuthorizationHeaderPresent()) { + String username = auth.getUsername(); + String primaryOffice = auth.getPrimaryOffice(); + List roles = auth.getRoles(); + + logger.info("Request from {} at office {} with roles {}", + username, primaryOffice, roles); +} +``` + +### Role-Based Access Check + +```java +AuthorizationContextHelper auth = new AuthorizationContextHelper(ctx); + +if (!auth.hasRole("CWMS Users")) { + ctx.status(403).result("CWMS Users role required"); + return; +} +``` + +### Office Access Validation + +```java +AuthorizationContextHelper auth = new AuthorizationContextHelper(ctx); +String requestedOffice = ctx.queryParam("office"); + +if (!auth.hasOfficeAccess(requestedOffice)) { + ctx.status(403).result("Not authorized for office: " + requestedOffice); + return; +} +``` + +### Conditional Authorization + +```java +if (AuthorizationContextHelper.isEnabled()) { + AuthorizationContextHelper auth = new AuthorizationContextHelper(ctx); + // Apply authorization logic +} else { + // Bypass authorization +} +``` + +## Expected Header Format + +The helper expects the `x-cwms-auth-context` header to contain JSON in the following structure: + +```json +{ + "policy": { + "allow": true, + "decision_id": "proxy-12345" + }, + "user": { + "id": "m5hectest", + "username": "m5hectest", + "email": "m5hectest@usace.army.mil", + "roles": ["cwms_user", "ts_id_creator"], + "offices": ["SWT", "SPK"], + "primary_office": "SWT", + "persona": "operator", + "region": "SWD" + }, + "constraints": { + "allowed_offices": ["SWT", "SPK"], + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"], + "timezone": "America/Chicago" + } +} +``` + +## Error Handling + +The helper logs warnings for parsing errors but does not throw exceptions. Invalid headers result in empty contexts: + +```java +AuthorizationContextHelper auth = new AuthorizationContextHelper(ctx); + +// Safe to call even if header was invalid +if (!auth.isAuthorizationHeaderPresent()) { + // Handle missing/invalid authorization +} +``` diff --git a/docs/source/access-management/integration/authorization-filter-helper.md b/docs/source/access-management/integration/authorization-filter-helper.md new file mode 100644 index 000000000..df1953d45 --- /dev/null +++ b/docs/source/access-management/integration/authorization-filter-helper.md @@ -0,0 +1,392 @@ +# AuthorizationFilterHelper + +The `AuthorizationFilterHelper` class generates JOOQ `Condition` objects from the constraints in the `x-cwms-auth-context` header for use in database queries. + +**Package**: `cwms.cda.helpers` + +**Source**: `cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationFilterHelper.java` + +## Purpose + +This helper class translates authorization constraints into database query conditions. It: + +- Parses constraints from the authorization context header +- Generates JOOQ conditions for office-based filtering +- Applies embargo rules based on time restrictions +- Enforces time window limitations +- Filters by data classification levels + +## Constructors + +### From Javalin Context + +```java +public AuthorizationFilterHelper(io.javalin.http.Context ctx) +``` + +Creates a helper by extracting constraints from the `x-cwms-auth-context` header. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ctx` | `io.javalin.http.Context` | The Javalin request context | + +**Behavior**: +- If authorization is disabled via `AuthorizationContextHelper.isEnabled()`, constraints are null +- If header is missing or invalid, constraints are null +- All filter methods return `DSL.noCondition()` when constraints are null + +### From JsonNode + +```java +public AuthorizationFilterHelper(JsonNode constraints) +``` + +Creates a helper from a pre-parsed constraints JSON node. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `constraints` | `com.fasterxml.jackson.databind.JsonNode` | Parsed constraints object | + +## Status Method + +### hasAuthorizationContext + +```java +public boolean hasAuthorizationContext() +``` + +Returns whether authorization constraints are present. + +**Returns**: `true` if constraints were successfully parsed + +## Filter Methods + +### getOfficeFilter + +```java +public Condition getOfficeFilter(Field officeField, String requestedOffice) +``` + +Generates a condition to filter results by allowed offices. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `officeField` | `Field` | The JOOQ field representing the office column | +| `requestedOffice` | `String` | The specific office requested (may be null) | + +**Returns**: JOOQ `Condition` object + +**Behavior**: + +| Scenario | Returned Condition | +|----------|-------------------| +| No constraints | `noCondition()` (no filtering) | +| No `allowed_offices` in constraints | `noCondition()` | +| `allowed_offices` contains `*` | `noCondition()` (all offices allowed) | +| `allowed_offices` is empty | `falseCondition()` (deny all) | +| `requestedOffice` not in allowed list | `falseCondition()` (deny) | +| `requestedOffice` in allowed list | `officeField.eq(requestedOffice)` | +| No `requestedOffice`, has allowed list | `officeField.in(allowedOffices)` | + +### getEmbargoFilter + +```java +public Condition getEmbargoFilter( + Field timestampField, + Field officeField, + String requestedOffice +) +``` + +Generates a condition to filter out embargoed data. Embargo rules restrict access to data newer than a specified number of hours. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `timestampField` | `Field` | The JOOQ field representing the timestamp column | +| `officeField` | `Field` | The JOOQ field representing the office column | +| `requestedOffice` | `String` | The specific office being queried | + +**Returns**: JOOQ `Condition` object + +**Behavior**: + +| Scenario | Returned Condition | +|----------|-------------------| +| No constraints | `noCondition()` | +| `embargo_exempt` is true | `noCondition()` | +| No `embargo_rules` | `noCondition()` | +| Office-specific rule exists | `timestampField.lessThan(cutoff)` | +| Default rule exists | `timestampField.lessThan(defaultCutoff)` | + +The cutoff timestamp is calculated as: `now - embargo_hours` + +For example, with a 168-hour embargo (7 days), users can only see data older than 7 days. + +### getTsGroupEmbargoFilter + +```java +public Condition getTsGroupEmbargoFilter( + Field timestampField, + String tsGroupId +) +``` + +Generates a condition to filter embargoed data based on time series group. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `timestampField` | `Field` | The JOOQ field representing the timestamp column | +| `tsGroupId` | `String` | The time series group identifier | + +**Returns**: JOOQ `Condition` object + +**Behavior**: + +| Scenario | Returned Condition | +|----------|-------------------| +| No constraints | `noCondition()` | +| `embargo_exempt` is true | `noCondition()` | +| No `ts_group_embargo` rules | `timestampField.lessThan(168-hour-cutoff)` | +| Group has 0 hours | `noCondition()` | +| Group-specific rule exists | `timestampField.lessThan(cutoff)` | +| Group not in rules | `timestampField.lessThan(168-hour-cutoff)` | + +### getTsGroupEmbargoHours + +```java +public int getTsGroupEmbargoHours(String tsGroupId) +``` + +Returns the embargo duration in hours for a specific time series group. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `tsGroupId` | `String` | The time series group identifier | + +**Returns**: Number of embargo hours (0 if no embargo applies) + +### getTimeWindowFilter + +```java +public Condition getTimeWindowFilter( + Field timestampField, + Timestamp userRequestedBeginTime +) +``` + +Generates a condition to restrict data to a recent time window. Unlike embargo rules (which hide recent data), time window rules limit how far back users can query. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `timestampField` | `Field` | The JOOQ field representing the timestamp column | +| `userRequestedBeginTime` | `Timestamp` | The begin time requested by the user (may be null) | + +**Returns**: JOOQ `Condition` object + +**Behavior**: + +| Scenario | Returned Condition | +|----------|-------------------| +| No constraints | `noCondition()` | +| No `time_window` | `noCondition()` | +| No `restrict_hours` | `noCondition()` | +| User time within window | `timestampField.greaterOrEqual(userRequestedBeginTime)` | +| User time before window | `timestampField.greaterOrEqual(cutoff)` | +| No user time specified | `timestampField.greaterOrEqual(cutoff)` | + +For example, with an 8-hour time window, a dam operator can only see data from the last 8 hours. + +### getClassificationFilter + +```java +public Condition getClassificationFilter(Field classificationField) +``` + +Generates a condition to filter by data classification level. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `classificationField` | `Field` | The JOOQ field representing the classification column | + +**Returns**: JOOQ `Condition` object + +**Behavior**: + +| Scenario | Returned Condition | +|----------|-------------------| +| No constraints | `noCondition()` | +| No `data_classification` | `noCondition()` | +| Empty classification list | `falseCondition()` (deny all) | +| Has allowed classifications | `classificationField.in(list).or(classificationField.isNull())` | + +Note: Records with null classification are included when classification filtering is active. + +### getAllFilters + +```java +public Condition getAllFilters( + Field officeField, + Field timestampField, + Field classificationField, + String requestedOffice, + Timestamp userRequestedBeginTime +) +``` + +Combines all filter types into a single condition using AND logic. + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `officeField` | `Field` | The JOOQ field for office | +| `timestampField` | `Field` | The JOOQ field for timestamp | +| `classificationField` | `Field` | The JOOQ field for classification (may be null) | +| `requestedOffice` | `String` | The specific office being queried | +| `userRequestedBeginTime` | `Timestamp` | The begin time requested by the user | + +**Returns**: Combined JOOQ `Condition` object + +**Behavior**: +- Returns `noCondition()` if no constraints are present +- Combines office, embargo, time window, and classification filters with AND +- Skips classification filter if `classificationField` is null + +## Usage Examples + +### Basic Query Filtering + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +SelectConditionStep query = dsl.selectFrom(TIMESERIES) + .where(filterHelper.getOfficeFilter(TIMESERIES.OFFICE_ID, requestedOffice)); +``` + +### Combined Filters + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +Condition authFilters = filterHelper.getAllFilters( + TIMESERIES.OFFICE_ID, + TIMESERIES.DATE_TIME, + TIMESERIES.CLASSIFICATION, + requestedOffice, + beginTime +); + +List results = dsl.selectFrom(TIMESERIES) + .where(authFilters) + .and(otherConditions) + .fetch(); +``` + +### Selective Filter Application + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +Condition baseCondition = LOCATIONS.OFFICE_ID.eq(office); + +if (filterHelper.hasAuthorizationContext()) { + Condition officeFilter = filterHelper.getOfficeFilter(LOCATIONS.OFFICE_ID, office); + baseCondition = baseCondition.and(officeFilter); +} + +List locations = dsl.selectFrom(LOCATIONS) + .where(baseCondition) + .fetch(); +``` + +### Embargo with Time Series Groups + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +String tsGroupId = determineTimeSeriesGroup(timeSeriesId); +Condition embargoFilter = filterHelper.getTsGroupEmbargoFilter( + TIMESERIES_VALUES.DATE_TIME, + tsGroupId +); + +List values = dsl.selectFrom(TIMESERIES_VALUES) + .where(TIMESERIES_VALUES.TS_CODE.eq(tsCode)) + .and(embargoFilter) + .fetch(); +``` + +### Checking Embargo Duration + +```java +AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + +int embargoHours = filterHelper.getTsGroupEmbargoHours("Reservoir-Levels"); + +if (embargoHours > 0) { + logger.info("Data embargoed for {} hours", embargoHours); +} +``` + +## Expected Constraints Format + +The helper expects the `constraints` object in the authorization context to have this structure: + +```json +{ + "allowed_offices": ["SWT", "SPK"], + "embargo_rules": { + "SPK": 168, + "SWT": 72, + "default": 168 + }, + "ts_group_embargo": { + "Reservoir-Levels": 24, + "Stream-Flow": 48 + }, + "embargo_exempt": false, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `allowed_offices` | array | Office codes user can access, or `["*"]` for all | +| `embargo_rules` | object | Hours of recent data to hide per office | +| `embargo_rules.default` | number | Default embargo hours when office not specified | +| `ts_group_embargo` | object | Hours of recent data to hide per TS group | +| `embargo_exempt` | boolean | If true, embargo rules do not apply | +| `time_window.restrict_hours` | number | Only show data from last N hours | +| `data_classification` | array | Classification levels user can access | + +## JOOQ Condition Reference + +| Condition | Effect | +|-----------|--------| +| `DSL.noCondition()` | No filtering (all rows pass) | +| `DSL.falseCondition()` | Block all rows | +| `field.eq(value)` | Exact match | +| `field.in(list)` | Match any in list | +| `field.lessThan(value)` | Before timestamp (for embargo) | +| `field.greaterOrEqual(value)` | After timestamp (for time window) | +| `DSL.and(conditions...)` | All conditions must pass | +| `DSL.or(conditions...)` | Any condition must pass | diff --git a/docs/source/access-management/integration/index.md b/docs/source/access-management/integration/index.md new file mode 100644 index 000000000..78062af06 --- /dev/null +++ b/docs/source/access-management/integration/index.md @@ -0,0 +1,169 @@ +# Java API Integration + +This section documents how the CWMS Data API integrates with the Authorization Proxy to enforce access control at the database level. + +## Overview + +The authorization architecture follows a clear separation of concerns: + +```mermaid +flowchart LR + subgraph Proxy + A[Authorization Proxy] + B[OPA Policy Engine] + end + subgraph JavaAPI[Java API] + C[AuthorizationContextHelper] + D[AuthorizationFilterHelper] + E[JOOQ Query Builder] + end + subgraph Database + F[Oracle DB] + end + + A --> B + B --> A + A -->|x-cwms-auth-context| C + C --> D + D --> E + E --> F +``` + +## Key Principle + +The Authorization Proxy makes authorization decisions and passes filtering constraints to the Java API via the `x-cwms-auth-context` header. The Java API does not make authorization decisions. Instead, it applies the constraints at the database query level to filter results. + +| Component | Responsibility | +|-----------|----------------| +| Authorization Proxy | Makes allow/deny decisions via OPA | +| OPA Policy Engine | Evaluates policies, determines constraints | +| AuthorizationContextHelper | Parses header, provides user context | +| AuthorizationFilterHelper | Generates JOOQ conditions from constraints | +| Database | Returns filtered results | + +## Helper Classes + +The Java API provides two helper classes for integration: + +### AuthorizationContextHelper + +Parses the `x-cwms-auth-context` header and provides access to user information and constraints. This class handles: + +- Extracting user identity (id, username, email) +- Retrieving user roles and office assignments +- Accessing constraint values +- Checking authorization header presence + +See [AuthorizationContextHelper](authorization-context-helper.md) for detailed documentation. + +### AuthorizationFilterHelper + +Generates JOOQ `Condition` objects from the constraints in the authorization context. This class handles: + +- Office-based filtering +- Embargo rules (time-based data restrictions) +- Time window restrictions +- Data classification filtering + +See [AuthorizationFilterHelper](authorization-filter-helper.md) for detailed documentation. + +## Enabling Authorization Mode + +Authorization integration is controlled by the `cwms.dataapi.access.management.enabled` configuration property. + +### Configuration Options + +| Method | Example | +|--------|---------| +| Environment Variable | `cwms.dataapi.access.management.enabled=true` | +| System Property | `-Dcwms.dataapi.access.management.enabled=true` | + +### Behavior by Mode + +**When Enabled (`true`)**: +- Authorization context header is parsed and validated +- Helper classes extract user context and constraints +- Filters are applied to database queries +- Requests without valid headers may be restricted + +**When Disabled (`false`, default)**: +- Authorization context header is ignored +- Helper classes return empty contexts +- No filters are applied to queries +- API behaves as if no authorization system exists + +```java +// Check if authorization mode is enabled +if (AuthorizationContextHelper.isEnabled()) { + // Apply authorization filters + AuthorizationContextHelper authContext = new AuthorizationContextHelper(ctx); + // ... +} +``` + +## Integration Flow + +```mermaid +sequenceDiagram + participant Client + participant Proxy as Authorization Proxy + participant OPA + participant API as Java API + participant DB as Oracle DB + + Client->>Proxy: Request with JWT + Proxy->>OPA: Evaluate policy + OPA-->>Proxy: Decision + constraints + Proxy->>API: Request + x-cwms-auth-context + API->>API: Parse header (ContextHelper) + API->>API: Build filters (FilterHelper) + API->>DB: Query with WHERE conditions + DB-->>API: Filtered results + API-->>Proxy: Response + Proxy-->>Client: Response +``` + +## Usage in Controllers + +Controllers integrate authorization filtering by: + +1. Creating helper instances from the Javalin context +2. Extracting relevant filter conditions +3. Applying conditions to JOOQ queries + +```java +public class TimeSeriesController extends BaseHandler { + + @Override + public void handle(Context ctx) { + AuthorizationContextHelper authContext = new AuthorizationContextHelper(ctx); + AuthorizationFilterHelper filterHelper = new AuthorizationFilterHelper(ctx); + + String requestedOffice = ctx.queryParam("office"); + + // Build query with authorization filters + Condition authFilters = filterHelper.getAllFilters( + TIMESERIES.OFFICE_ID, + TIMESERIES.DATE_TIME, + TIMESERIES.CLASSIFICATION, + requestedOffice, + userRequestedBeginTime + ); + + // Apply filters to query + List results = dsl.selectFrom(TIMESERIES) + .where(authFilters) + .fetch(); + } +} +``` + +## Contents + +```{toctree} +:maxdepth: 2 + +authorization-context-helper +authorization-filter-helper +testing +``` diff --git a/docs/source/access-management/integration/testing.md b/docs/source/access-management/integration/testing.md new file mode 100644 index 000000000..2c30b2909 --- /dev/null +++ b/docs/source/access-management/integration/testing.md @@ -0,0 +1,117 @@ +# Testing + +The access management integration is tested through the existing CWMS Data API test infrastructure rather than isolated unit tests. This approach validates the authorization helpers work correctly within the actual request lifecycle. + +## Test Strategy + +Authorization filtering is verified through integration tests that exercise the full request path. The helpers are designed to be transparent - when disabled, they return no-op conditions that don't affect queries. + +| Approach | Coverage | +|----------|----------| +| Integration tests | Full request lifecycle with real database | +| Parameterized users | Different auth methods and permission levels | +| Existing endpoint tests | Verify filtering doesn't break current behavior | + +## Running Tests + +```bash +# Unit tests (fast, no database) +./gradlew test + +# Integration tests (requires database) +./gradlew integrationTests + +# Specific test class +./gradlew test --tests "*AuthorizationContextHelper*" +``` + +## Test User Fixtures + +The `UserSpecSource` class provides parameterized test users with different authentication methods: + +```java +@ParameterizedTest +@MethodSource("fixtures.users.UserSpecSource#userSpecsValidPrivs") +void testWithAuthorizedUser(String user, Consumer auth) { + given() + .spec(auth) + .when() + .get("/timeseries") + .then() + .statusCode(200); +} +``` + +Available user specs: + +| Method | Users Provided | +|--------|----------------| +| `userSpecsValidPrivs()` | Users with valid API keys and CWMS AAA sessions | +| `usersNoPrivs()` | Users without any permissions | +| `apiKeyUser()` | Single API key authenticated user | +| `cwmsAaaUser()` | Single CWMS AAA session user | + +## Testing with Access Management Disabled + +By default, tests run with access management disabled. To test authorization behavior: + +```bash +# Enable access management for tests +./gradlew integrationTests -Dcwms.dataapi.access.management.enabled=true +``` + +When disabled, the helpers return: +- `DSL.noCondition()` for all filters (no restrictions) +- Empty lists for roles and offices +- `false` for `isAuthorizationHeaderPresent()` + +## Writing Authorization-Aware Tests + +For tests that need to verify authorization behavior: + +```java +@Test +void testOfficeFiltering() { + // Set up mock authorization context + String authContext = """ + { + "user": {"offices": ["SWT"]}, + "constraints": {"allowed_offices": ["SWT"]} + } + """; + + given() + .header("x-cwms-auth-context", authContext) + .when() + .get("/timeseries?office=SWT") + .then() + .statusCode(200); +} +``` + +## Integration Test Base + +All integration tests extend `DataApiTestIT`, which handles: + +- Database connection setup +- Test data lifecycle management +- Automatic cleanup after tests +- Connection to TestContainers or bypass database + +```java +class MyAuthorizationTestIT extends DataApiTestIT { + @Test + void testAuthorizedAccess() { + // Test implementation + } +} +``` + +## Related Files + +| File | Purpose | +|------|---------| +| `fixtures/users/UserSpecSource.java` | Parameterized user providers | +| `fixtures/users/annotation/AuthType.java` | Auth type annotations | +| `api/auth/ApiKeyControllerTestIT.java` | API key authentication tests | +| `api/auth/OpenIdConnectTestIT.java` | Keycloak integration tests | diff --git a/docs/source/access-management/management/index.md b/docs/source/access-management/management/index.md new file mode 100644 index 000000000..3af3c5982 --- /dev/null +++ b/docs/source/access-management/management/index.md @@ -0,0 +1,97 @@ +# Management Applications + +The CWMS Access Management system provides two management interfaces for administrators to view users, roles, and authorization policies: a web-based Management UI and a command-line Management CLI. + +## Overview + +Both applications connect to the Authorization Proxy management API to retrieve and display authorization data. They are read-only interfaces designed for monitoring and administration purposes. + +```mermaid +graph LR + UI[Management UI] + CLI[Management CLI] + API[Authorization Proxy] + OPA[OPA Server] + DB[(Oracle Database)] + + UI --> API + CLI --> API + API --> OPA + API --> DB +``` + +## Comparison + +| Feature | Management UI | Management CLI | +|---------|--------------|----------------| +| Interface | Web browser | Terminal | +| Authentication | Form-based login | Token-based login | +| User listing | Searchable table | Formatted table | +| User details | Detailed view | Show command | +| Role listing | Card view | Formatted table | +| Role details | Card expansion | Show command | +| Policy listing | Card view | Formatted table | +| Policy details | Card expansion | Show command | +| Session storage | Browser localStorage | Config file | +| Deployment | Container or static | Standalone binary | +| Best for | Interactive browsing | Scripting and automation | + +## When to Use Each Tool + +### Management UI + +Use the web interface when: + +- Browsing and searching through users interactively +- Presenting authorization data to non-technical stakeholders +- Working from a machine without CLI access +- Training new administrators + +### Management CLI + +Use the command-line interface when: + +- Automating administrative tasks with scripts +- Working in headless or SSH environments +- Integrating with CI/CD pipelines +- Performing quick lookups from the terminal + +## Common Capabilities + +Both applications support: + +- Secure authentication with JWT tokens +- View all registered users and their status +- View role definitions and descriptions +- View OPA authorization policies +- Automatic session management + +## Technology Stack + +| Component | Management UI | Management CLI | +|-----------|--------------|----------------| +| Runtime | Browser | Node.js 24+ | +| Language | TypeScript | TypeScript | +| Framework | React 18 | Commander | +| Bundler | Vite 6 | esbuild | +| State | Zustand, TanStack Query | Local config file | +| Styling | Tailwind CSS | Ink, Chalk | +| HTTP Client | Axios | Axios | +| Validation | Zod | Zod | + +## Port Assignments + +| Service | Default Port | +|---------|-------------| +| Management UI | 4200 | +| Management CLI | N/A (connects to proxy) | +| Authorization Proxy API | 3001 (proxy), 3002 (management) | + +## Detailed Documentation + +```{toctree} +:maxdepth: 1 + +management-ui +management-cli +``` diff --git a/docs/source/access-management/management/management-cli.md b/docs/source/access-management/management/management-cli.md new file mode 100644 index 000000000..241325bc1 --- /dev/null +++ b/docs/source/access-management/management/management-cli.md @@ -0,0 +1,491 @@ +# Management CLI + +The CWMS Access Management CLI (cwms-admin) provides command-line access to manage users, roles, and authorization policies for the CWMS system. It offers an interactive terminal interface with formatted tables, colored output, and loading spinners. + +## Technology Stack + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Runtime | Node.js 24+ | JavaScript execution environment | +| Language | TypeScript 5.6+ | Type-safe development | +| CLI Framework | Commander | Command parsing and help generation | +| Terminal UI | Ink | React-based terminal rendering | +| HTTP Client | Axios | API communication | +| Colors | Chalk | Terminal text styling | +| Spinners | Ora | Loading state indicators | +| Logging | Pino | Structured JSON logging | +| Validation | Zod | Schema validation | + +## Features + +- User management operations (list and view details) +- Role management operations (list and view details) +- Policy management operations (list and view details) +- Authentication with token persistence +- Formatted table output with box-drawing characters +- Colored status indicators +- Loading spinners during API calls +- Structured logging for debugging + +## Installation + +### NPM Installation (Recommended) + +```bash +npm install -g @usace/cwms-admin +``` + +### Download and Install + +Download the appropriate archive for your platform: + +| Platform | File | +|----------|------| +| macOS (Apple Silicon) | cwms-admin-v0.1.0-darwin-arm64.tar.gz | +| macOS (Intel) | cwms-admin-v0.1.0-darwin-x64.tar.gz | +| Linux | cwms-admin-v0.1.0-linux-x64.tar.gz | +| Any platform | cwms-admin-v0.1.0-portable.zip | + +Extract and run the installer: + +```bash +tar -xzf cwms-admin-v0.1.0-*.tar.gz +chmod +x install-from-archive.sh +./install-from-archive.sh +``` + +### Global Link from Source + +For development, link from the built distribution: + +```bash +cd dist/apps/cli/management-cli +npm link +``` + +## System Requirements + +| Requirement | Minimum | Recommended | +|-------------|---------|-------------| +| Node.js | 20.0.0 | 24.0.0+ | +| Disk Space | 50MB | 100MB | +| OS | macOS, Linux, Windows 10+ | macOS, Linux | +| Terminal | Unicode support | Color support | + +## Configuration + +### Configuration File + +The CLI stores configuration in `~/.cwms-admin/config.json`: + +```json +{ + "apiUrl": "http://localhost:3002", + "token": "your-auth-token", + "username": "admin" +} +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| LOG_LEVEL | Logging level (debug, info, warn, error) | info | +| NODE_ENV | Environment (development, production) | production | +| MANAGEMENT_API_URL | Default API URL | http://localhost:3002 | +| KEYCLOAK_ADMIN_USER | Default admin username | admin | +| KEYCLOAK_ADMIN_PASSWORD | Default admin password | admin | + +## Command Reference + +### Global Options + +```bash +cwms-admin --version # Display version number +cwms-admin --help # Display help information +``` + +### Authentication Commands + +#### login + +Authenticate with the management API and store credentials. + +```bash +cwms-admin login [options] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| -u, --username | Admin username | admin | +| -p, --password | Admin password | admin | +| -a, --api-url | Management API URL | http://localhost:3002 | + +Examples: + +```bash +cwms-admin login -u admin -p password +cwms-admin login -u admin -p password -a http://api.example.com:3002 +``` + +On success, the authentication token is saved to `~/.cwms-admin/config.json`. + +#### logout + +Clear stored credentials and log out. + +```bash +cwms-admin logout +``` + +### Users Commands + +#### users list + +Display all users in a formatted table. + +```bash +cwms-admin users list +``` + +Output columns: + +| Column | Description | +|--------|-------------| +| Username | User's login name | +| ID | Unique user identifier | +| Email | User's email address | +| Name | Full name (first + last) | +| Status | Enabled or Disabled (color coded) | + +#### users show + +Display detailed information for a specific user. + +```bash +cwms-admin users show +``` + +| Argument | Description | +|----------|-------------| +| id | User ID or username to display | + +Example: + +```bash +cwms-admin users show m5hectest +``` + +Output fields: + +| Field | Description | +|-------|-------------| +| Username | User's login name | +| ID | Unique identifier | +| Email | Email address (if set) | +| First Name | First name (if set) | +| Last Name | Last name (if set) | +| Status | Enabled or Disabled | + +### Roles Commands + +#### roles list + +Display all roles in a formatted table. + +```bash +cwms-admin roles list +``` + +Output columns: + +| Column | Description | +|--------|-------------| +| Name | Role name | +| ID | Unique role identifier | +| Description | Role description | + +#### roles show + +Display detailed information for a specific role. + +```bash +cwms-admin roles show +``` + +| Argument | Description | +|----------|-------------| +| id | Role ID to display | + +Example: + +```bash +cwms-admin roles show cwms_user +``` + +Output fields: + +| Field | Description | +|-------|-------------| +| Name | Role name | +| ID | Unique identifier | +| Description | Role description (if set) | + +### Policies Commands + +#### policies list + +Display all authorization policies in a formatted table. + +```bash +cwms-admin policies list +``` + +Output columns: + +| Column | Description | +|--------|-------------| +| Name | Policy name | +| ID | Unique policy identifier | +| Description | Policy description | + +#### policies show + +Display detailed information for a specific policy, including rule definitions. + +```bash +cwms-admin policies show +``` + +| Argument | Description | +|----------|-------------| +| id | Policy ID to display | + +Example: + +```bash +cwms-admin policies show office-restriction +``` + +Output fields: + +| Field | Description | +|-------|-------------| +| Name | Policy name | +| ID | Unique identifier | +| Description | Policy description | +| Rules | JSON-formatted policy rules | + +## Output Formats + +### Table Output + +List commands display data in formatted tables with box-drawing characters: + +``` +Found 3 users ++-----------+------+-----------------+------------+---------+ +| Username | ID | Email | Name | Status | ++-----------+------+-----------------+------------+---------+ +| m5hectest | 001 | m5@test.com | M5 Test | Enabled | +| l2hectest | 002 | l2@test.com | L2 Test | Enabled | +| l1hectest | 003 | - | L1 Test | Disabled| ++-----------+------+-----------------+------------+---------+ +``` + +### Detail Output + +Show commands display key-value pairs with aligned labels: + +``` +User Details +Username: m5hectest +ID: 001 +Email: m5@test.com +First Name: M5 +Last Name: Test +Status: Enabled +``` + +### Status Colors + +| Status | Color | +|--------|-------| +| Enabled | Green | +| Disabled | Red | +| Loading | Cyan | +| Warning | Yellow | +| Error | Red | + +## Exit Codes + +| Code | Description | +|------|-------------| +| 0 | Success | +| 1 | General error (authentication failure, API error, validation error) | + +## Building from Source + +### Prerequisites + +- Node.js 24+ +- pnpm 10+ +- Access to the cwms-access-management monorepo + +### Build Steps + +```bash +cd cwms-access-management + +pnpm install + +pnpm nx build management-cli --configuration=production +``` + +Output location: `dist/apps/cli/management-cli/index.js` + +### Development Mode + +Run with hot reload during development: + +```bash +pnpm nx serve management-cli +``` + +Or using tsx directly: + +```bash +cd apps/cli/management-cli +pnpm dev +``` + +### Create Distribution Package + +```bash +./apps/cli/management-cli/scripts/build-executable.sh +``` + +Output in `./release/` directory: + +- Platform-specific tarballs (darwin-arm64, darwin-x64, linux-x64) +- Cross-platform portable ZIP archive + +## Troubleshooting + +### Command not found + +If you see "command not found: cwms-admin" after npm installation: + +```bash +export PATH="$PATH:$(npm bin -g)" +``` + +Add this line to `~/.bashrc` or `~/.zshrc` for persistence. + +### Permission denied + +For npm permission errors during global installation: + +```bash +npm config set prefix ~/.npm-global +export PATH=~/.npm-global/bin:$PATH +npm install -g @usace/cwms-admin +``` + +### Cannot connect to API + +Verify your configuration: + +```bash +cat ~/.cwms-admin/config.json +``` + +Re-authenticate with the correct API URL: + +```bash +cwms-admin login -u admin -p password -a http://correct-api-url:3002 +``` + +### Authentication required error + +If commands fail with "Not authenticated. Please run: cwms-admin login": + +```bash +cwms-admin login -u admin -p password +``` + +### Table rendering issues + +Ensure your terminal supports Unicode: + +```bash +echo $LANG +export LANG=en_US.UTF-8 +``` + +## API Integration + +The CLI communicates with the Management API service (default port 3002). All requests include the stored authentication token in the Authorization header. + +### Endpoints Used + +| Command | Method | Endpoint | +|---------|--------|----------| +| users list | GET | /users | +| users show | GET | /users/:id | +| roles list | GET | /roles | +| roles show | GET | /roles/:id | +| policies list | GET | /policies | +| policies show | GET | /policies/:id | +| login | POST | /login | + +### Request Timeout + +All API requests have a 10-second timeout. For slow network connections, ensure the Management API is accessible and responsive. + +## Project Structure + +``` +apps/cli/management-cli/ +├── src/ +│ ├── commands/ # Command implementations +│ │ ├── login.ts # Authentication commands +│ │ ├── users.tsx # User management commands +│ │ ├── roles.tsx # Role management commands +│ │ └── policies.tsx # Policy management commands +│ ├── ink/ +│ │ ├── components/ # Reusable UI components +│ │ │ ├── ink-table.tsx # Table component +│ │ │ └── status-message.tsx # Status display +│ │ ├── screens/ # Command output screens +│ │ │ ├── users-list.tsx +│ │ │ ├── user-details.tsx +│ │ │ ├── roles-list.tsx +│ │ │ ├── role-details.tsx +│ │ │ ├── policies-list.tsx +│ │ │ └── policy-details.tsx +│ │ └── render.ts # Ink rendering utilities +│ ├── services/ +│ │ └── api.service.ts # API client +│ ├── utils/ +│ │ ├── config.ts # Configuration management +│ │ ├── error.ts # Error handling utilities +│ │ ├── logger.ts # Pino logger setup +│ │ └── version.ts # Version utilities +│ └── index.ts # CLI entry point +├── scripts/ +│ ├── build-executable.sh # Distribution build script +│ ├── install-from-archive.sh # User installation script +│ └── prepare-dist.sh # Distribution preparation +├── docs/ +│ ├── installation.md # End-user installation guide +│ └── distribution.md # Build and distribution guide +├── package.json +└── tsconfig.json +``` + +## Related Documentation + +- [Management UI](management-ui.md) - Web-based management interface +- [Architecture Overview](../architecture/index.md) - System architecture +- [Proxy API Reference](../proxy-api/index.md) - Authorization proxy API documentation diff --git a/docs/source/access-management/management/management-ui.md b/docs/source/access-management/management/management-ui.md new file mode 100644 index 000000000..d3f456d18 --- /dev/null +++ b/docs/source/access-management/management/management-ui.md @@ -0,0 +1,334 @@ +# Management UI + +The Management UI is a web-based interface for viewing CWMS authorization data including users, roles, and OPA policies. It provides a responsive, modern interface for administrators to browse and search authorization information. + +## Purpose + +The Management UI serves as the primary visual interface for: + +- Browsing registered users and their account status +- Viewing role definitions and their descriptions +- Inspecting OPA authorization policies +- Searching and filtering user lists + +This is a read-only interface. Administrative operations that modify data require the Management CLI or direct API access. + +## Technology Stack + +| Component | Technology | Version | +|-----------|------------|---------| +| UI Library | React | 18.3.1 | +| Build Tool | Vite | 6.x | +| Language | TypeScript | 5.6+ | +| Routing | React Router | 7.x | +| Data Fetching | TanStack Query | 5.x | +| State Management | Zustand | 5.x | +| Styling | Tailwind CSS | 3.4.x | +| HTTP Client | Axios | 1.x | +| Logging | Pino | 10.x | +| Testing | Vitest | 3.x | +| API Mocking | MSW | 2.x | + +## Features + +### Authentication + +- Form-based login with username and password +- JWT token storage in browser localStorage +- Automatic redirect to login on session expiration +- Logout functionality with token cleanup + +### Users Page + +- Paginated table of all registered users +- Real-time search filtering by username, email, or name +- User status indicators (active/inactive) +- Display of user ID, email, and full name + +### Roles Page + +- List view of all role definitions +- Role name and description display +- Role ID for reference + +### Policies Page + +- List view of OPA authorization policies +- Policy name and description display +- Policy ID for reference + +### Navigation + +- Responsive navigation bar +- Current user display with logout option +- Protected routes requiring authentication + +## Installation + +### Prerequisites + +- Node.js 24 or higher +- pnpm 10 or higher + +### From Monorepo + +Install dependencies from the monorepo root: + +```bash +pnpm install +``` + +## Configuration + +### Environment Variables + +Create a `.env` file in the application directory or set environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `VITE_API_URL` | Authorization Proxy management API URL | `http://localhost:3002` | +| `VITE_LOG_LEVEL` | Logging level (debug, info, warn, error) | `info` | +| `VITE_ENABLE_MSW` | Enable Mock Service Worker for development | `false` | + +### Example Configuration + +```bash +VITE_API_URL=http://localhost:3002 +VITE_LOG_LEVEL=info +VITE_ENABLE_MSW=false +``` + +## Running in Development + +Start the development server with hot reload: + +```bash +pnpm nx serve management-ui +``` + +The application will be available at [http://localhost:4200](http://localhost:4200). + +### Development Features + +- Hot module replacement for instant updates +- Source maps for debugging +- MSW integration for API mocking +- TypeScript type checking + +## Building for Production + +Build the optimized production bundle: + +```bash +pnpm nx build management-ui --configuration=production +``` + +Output location: `dist/apps/web/management-ui/` + +### Preview Production Build + +Test the production build locally: + +```bash +pnpm nx preview management-ui +``` + +Available at [http://localhost:4300](http://localhost:4300). + +## Docker Deployment + +### Building the Image + +Build the Docker image from the monorepo root: + +```bash +podman build \ + -t cwms-management-ui:local-dev \ + -f apps/web/management-ui/Dockerfile \ + --build-arg VITE_API_URL=http://localhost:3002 \ + . +``` + +The build argument `VITE_API_URL` is baked into the static assets at build time. + +### Running the Container + +```bash +podman run -d \ + --name management-ui \ + -p 4200:80 \ + cwms-management-ui:local-dev +``` + +### Docker Compose + +The application is included in the monorepo docker-compose configuration: + +```bash +podman compose -f docker-compose.podman.yml up -d management-ui +``` + +## Project Structure + +``` +src/ +├── components/ # Reusable UI components +│ └── ui/ # Base UI components (Button, Card, Input, Label) +├── contexts/ # React context providers +│ └── AuthContext.tsx # Authentication state management +├── pages/ # Page components for routing +│ ├── HomePage.tsx # Dashboard landing page +│ ├── LoginPage.tsx # Authentication page +│ ├── UsersPage.tsx # User listing and search +│ ├── RolesPage.tsx # Role listing +│ └── PoliciesPage.tsx # Policy listing +├── services/ # API clients +│ └── api.service.ts # Management API client +├── utils/ # Utility functions +│ ├── logger.ts # Pino logger configuration +│ └── utils.ts # General utilities +├── lib/ # Third-party integrations +│ └── utils.ts # Tailwind class utilities +├── App.tsx # Main application with routing +├── main.tsx # Application entry point +└── index.css # Global styles and Tailwind imports +``` + +## API Integration + +The UI connects to the Authorization Proxy management API: + +```mermaid +sequenceDiagram + participant Browser + participant UI + participant API + participant DB + + Browser->>UI: Login request + UI->>API: POST /login + API->>DB: Validate credentials + DB-->>API: User data + API-->>UI: JWT token + UI->>Browser: Store token + + Browser->>UI: View users + UI->>API: GET /users (with JWT) + API->>DB: Query users + DB-->>API: User list + API-->>UI: User data + UI->>Browser: Render table +``` + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/login` | POST | Authenticate and receive JWT token | +| `/users` | GET | List all users | +| `/users/:id` | GET | Get user details | +| `/roles` | GET | List all roles | +| `/roles/:id` | GET | Get role details | +| `/policies` | GET | List all policies | +| `/policies/:id` | GET | Get policy details | + +## Available Scripts + +| Command | Description | +|---------|-------------| +| `pnpm nx serve management-ui` | Start development server | +| `pnpm nx build management-ui` | Build for production | +| `pnpm nx build management-ui --configuration=production` | Production build with optimizations | +| `pnpm nx preview management-ui` | Preview production build | +| `pnpm nx lint management-ui` | Run ESLint | +| `pnpm nx test management-ui` | Run tests | +| `pnpm nx test management-ui --coverage` | Run tests with coverage | +| `pnpm nx typecheck management-ui` | Run TypeScript type checking | + +## Authentication Flow + +1. User navigates to application +2. Protected routes check for existing token in localStorage +3. If no token, redirect to login page +4. User submits credentials +5. API returns JWT token on success +6. Token stored in localStorage and Zustand state +7. Subsequent API requests include token in Authorization header +8. On 401 response, token cleared and user redirected to login + +## Component Library + +The UI uses a custom component library built on Radix UI primitives: + +| Component | Description | +|-----------|-------------| +| Button | Action buttons with variants | +| Card | Container with header and content sections | +| Input | Form text input | +| Label | Form field labels | + +Components use Tailwind CSS for styling with the `class-variance-authority` library for variant management. + +## Testing + +Run the test suite: + +```bash +pnpm nx test management-ui +``` + +Run tests with the visual UI: + +```bash +pnpm nx test management-ui --ui +``` + +Generate coverage report: + +```bash +pnpm nx test management-ui --coverage +``` + +The test setup includes: + +- Vitest as the test runner +- MSW for API mocking +- React Testing Library for component tests + +## Troubleshooting + +### Application shows loading indefinitely + +Verify the API URL configuration matches the running Authorization Proxy: + +```bash +# Check if proxy is running +podman ps | grep authorizer-proxy + +# Verify API URL in .env +cat .env | grep VITE_API_URL +``` + +### Login fails with network error + +Ensure the Authorization Proxy management server is accessible: + +```bash +curl http://localhost:3002/health +``` + +### Build fails with TypeScript errors + +Run type checking to identify issues: + +```bash +pnpm nx typecheck management-ui +``` + +### Styles not loading in production + +Verify Tailwind CSS is properly configured and PostCSS is processing styles: + +```bash +pnpm nx build management-ui --verbose +``` diff --git a/docs/source/access-management/performance/benchmark-results.md b/docs/source/access-management/performance/benchmark-results.md new file mode 100644 index 000000000..cf3c8a4a0 --- /dev/null +++ b/docs/source/access-management/performance/benchmark-results.md @@ -0,0 +1,146 @@ +# Benchmark Results + +This page documents benchmark results from testing the authorization proxy. These numbers provide a baseline for capacity planning, though actual production performance will vary based on hardware, network conditions, and workload patterns. + +## Test Environment + +The benchmarks were run on a local development machine with all services running in Podman containers. + +| Component | Details | +|-----------|---------| +| Machine | Apple M3 Max | +| Memory | 36GB RAM | +| OS | macOS Tahoe (Darwin 25.2.0) | +| Container Runtime | Podman 5.x | +| Node.js | 24.x (in container) | +| k6 Version | 1.5.0 | + +All services ran with default container resource limits. Production deployments with dedicated resources will see different numbers. + +## Request Latency + +The proxy adds minimal overhead to requests. Most of the time is spent in cache lookups and, when necessary, OPA policy evaluation. + +| Endpoint | Requests | Avg Latency | p95 Latency | Throughput | +|----------|----------|-------------|-------------|------------| +| `/health` | 6,695 | 1.2ms | 2ms | ~220 req/s | +| `/authorize` | 2,092 | 2.2ms | 3ms | ~70 req/s | +| `/cwms-data/timeseries` | 2,153 | 12ms | 15ms | ~70 req/s | +| Overall | 10,940 | 5.7ms | 12.4ms | ~177 req/s | + +The `/health` endpoint shows the baseline proxy overhead. The `/authorize` endpoint includes JWT decoding and OPA cache lookup. Authenticated proxy requests include the full authorization flow plus time spent waiting for the downstream API. + +## Cache Performance + +Caching dramatically reduces latency for repeated requests. The proxy uses two cache layers: + +| Cache Type | Hits | Misses | Hit Rate | Avg Lookup Time | +|------------|------|--------|----------|-----------------| +| User Context (Redis) | 4,235 | 10 | 99.76% | 0.23ms | +| OPA Decisions (In-Memory) | 4,238 | 7 | 99.84% | <0.1ms | + +The high hit rates reflect typical workloads where the same users make many requests. New users or cache expiration will cause misses that require backend lookups. + +### Cache Miss Impact + +When caches miss, latency increases significantly: + +| Operation | Cached | Uncached | +|-----------|--------|----------| +| User context lookup | <1ms | ~175ms | +| OPA decision | <0.1ms | 6-11ms | +| Total proxy overhead | 2-3ms | 180-200ms | + +The user context lookup dominates uncached latency because it requires an API call to fetch user profile information from the CWMS Data API. + +## OPA Policy Evaluation + +Policy evaluation happens only on cache misses. The evaluation time depends on policy complexity and the authorization decision: + +| Decision | Count | Avg Duration | p95 Duration | +|----------|-------|--------------|--------------| +| Allow | 3 | 6.4ms | ~10ms | +| Deny | 4 | 11ms | ~25ms | +| Total | 7 | 9ms | ~20ms | + +Deny decisions take longer because OPA often needs to evaluate more rules before determining that access should be blocked. + +## Latency Breakdown + +For an authenticated request, here is where time is spent: + +```mermaid +flowchart LR + subgraph Proxy["Authorization Proxy"] + JWT[JWT Decode
less than 1ms] + Redis[Redis Lookup
0.23ms hit / 1.2ms miss] + OPA[OPA Check
less than 0.1ms hit / 6-11ms miss] + end + subgraph Backend["Backend API"] + API[Request Processing
variable] + end + Client --> JWT --> Redis --> OPA --> API --> Client +``` + +Best case (cache hits): 2-3ms total proxy overhead +Worst case (cache misses): 180-200ms (dominated by user lookup API call) + +## Resource Utilization + +During the 30-second benchmark with 10 concurrent users: + +| Resource | Measurement | +|----------|-------------| +| CPU (proxy container) | 7.6 seconds total | +| Heap Size | 175MB | +| GC Events | 24 minor, 6 major | +| Peak Connections | 13 | + +The proxy maintains a steady memory footprint without significant growth over the test duration. + +## Throughput Under Load + +The stress test ramped from 5 to 50 virtual users over 60 seconds: + +| VU Count | Throughput | p95 Latency | Error Rate | +|----------|------------|-------------|------------| +| 5 | ~50 req/s | 8ms | 0% | +| 20 | ~150 req/s | 15ms | 0% | +| 50 | ~180 req/s | 45ms | 0% | + +Throughput scaled linearly up to about 30 VUs, then began to plateau as the proxy approached its capacity on the test hardware. + +## Comparison with Direct API Access + +To isolate the proxy overhead, we compared requests through the proxy versus direct API calls: + +| Path | Direct API | Through Proxy | Overhead | +|------|------------|---------------|----------| +| `/cwms-data/offices` | 8ms | 10ms | +2ms | +| `/cwms-data/timeseries` | 10ms | 13ms | +3ms | + +The 2-3ms overhead includes JWT decoding, cache lookups, and header injection. For most use cases, this is negligible compared to the security benefits. + +## Recommendations + +Based on these benchmarks: + +**For Production Deployment:** +- Deploy multiple proxy instances behind a load balancer for horizontal scaling +- Use Redis Cluster or Sentinel for cache high availability +- Monitor cache hit rates; rates below 90% may indicate configuration issues + +**For Performance Tuning:** +- Increase OPA decision cache TTL if policies change infrequently +- Consider pre-warming the cache for known high-traffic users +- Ensure Redis connection pooling is properly sized + +**Alerting Thresholds:** + +| Metric | Warning | Critical | +|--------|---------|----------| +| p95 latency | >100ms | >500ms | +| Cache hit rate | <90% | <80% | +| OPA evaluation p95 | >50ms | >100ms | +| Error rate | >1% | >5% | + diff --git a/docs/source/access-management/performance/index.md b/docs/source/access-management/performance/index.md new file mode 100644 index 000000000..99b2918f8 --- /dev/null +++ b/docs/source/access-management/performance/index.md @@ -0,0 +1,59 @@ +# Performance Testing + +Understanding how the authorization proxy performs under load helps inform capacity planning and identify potential bottlenecks. This section covers the benchmarking tools, how to run performance tests, and what the results mean. + +## Why Performance Testing Matters + +The authorization proxy sits in the critical path for every request to the CWMS Data API. Even small latency increases can compound across thousands of requests. Performance testing helps answer questions like: + +- How much overhead does the proxy add to each request? +- At what point does the system start to degrade under load? +- Is the caching strategy effective? +- How does OPA policy evaluation scale? + +## Metrics Collected + +The proxy exposes Prometheus-compatible metrics at the `/metrics` endpoint. These provide insight into both real-time behavior and historical trends. + +| Metric Category | What It Measures | +|-----------------|------------------| +| Request latency | Time to process each request, broken down by endpoint | +| Cache performance | Hit and miss rates for both Redis and OPA caches | +| OPA evaluation | Time spent evaluating authorization policies | +| API calls | Latency when fetching user context from the backend | +| Connection tracking | Number of concurrent connections being handled | + +## Testing Approach + +Performance tests use [k6](https://k6.io/), a load testing tool that runs scenarios with simulated virtual users. The tests authenticate against Keycloak, make requests through the proxy, and measure response times. + +The test suite includes several scenarios that exercise different aspects of the system: + +| Scenario | Purpose | +|----------|---------| +| Public endpoints | Baseline measurement of proxy overhead | +| Authenticated with warm cache | Typical production behavior where users are already cached | +| Authenticated with cold cache | Worst-case latency when cache misses require backend calls | +| Direct authorization | Isolated measurement of policy evaluation | +| Stress test | System behavior under increasing load | + +## Quick Start + +For those wanting to run a quick benchmark locally: + +```bash +cd cwms-access-management/tools/benchmark + +# Run a 30-second quick test +k6 run quick-benchmark.js +``` + +Detailed instructions and result interpretation are covered in the following pages. + +```{toctree} +:maxdepth: 2 + +running-benchmarks +benchmark-results +metrics-reference +``` diff --git a/docs/source/access-management/performance/metrics-reference.md b/docs/source/access-management/performance/metrics-reference.md new file mode 100644 index 000000000..728353c2f --- /dev/null +++ b/docs/source/access-management/performance/metrics-reference.md @@ -0,0 +1,181 @@ +# Metrics Reference + +The authorization proxy exposes Prometheus-compatible metrics at the `/metrics` endpoint. These metrics provide visibility into request latency, cache effectiveness, and authorization decisions. + +## Accessing Metrics + +### Prometheus Format + +```bash +curl http://localhost:3001/metrics +``` + +Returns metrics in Prometheus text exposition format: + +``` +# HELP authorizer_proxy_http_requests_total Total HTTP requests +# TYPE authorizer_proxy_http_requests_total counter +authorizer_proxy_http_requests_total{method="GET",route="/health",status_code="200"} 6695 +``` + +### JSON Format + +```bash +curl http://localhost:3001/metrics/json | jq +``` + +Returns metrics as a JSON object for easier programmatic access: + +```json +{ + "http_requests_total": { + "GET /health 200": 6695, + "POST /authorize 200": 2092 + }, + "cache_hits_total": { + "user_context": 4235 + } +} +``` + +## Available Metrics + +### HTTP Request Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_proxy_http_requests_total` | Counter | method, route, status_code | Total HTTP requests received | +| `authorizer_proxy_http_request_duration_seconds` | Histogram | method, route, status_code | Request latency distribution | +| `authorizer_proxy_active_connections` | Gauge | - | Current number of active connections | + +The request duration histogram uses default Prometheus buckets: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 seconds. + +### Cache Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_proxy_cache_hits_total` | Counter | cache_type | Successful cache lookups | +| `authorizer_proxy_cache_misses_total` | Counter | cache_type | Cache lookup failures | +| `authorizer_proxy_cache_operation_duration_seconds` | Histogram | operation, result | Time spent on cache operations | + +Cache types include: +- `user_context` - Redis cache for user profile data + +Cache operations include: +- `get` - Cache read operations +- `set` - Cache write operations + +### OPA Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_proxy_opa_evaluations_total` | Counter | resource, action, decision | Policy evaluations by outcome | +| `authorizer_proxy_opa_evaluation_duration_seconds` | Histogram | resource, action, decision | Time spent evaluating policies | +| `authorizer_proxy_opa_cache_hits_total` | Counter | - | OPA decision cache hits | +| `authorizer_proxy_opa_cache_misses_total` | Counter | - | OPA decision cache misses | + +The decision label values are `allow` or `deny`. + +### API Call Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_proxy_api_calls_total` | Counter | endpoint, status | Calls to downstream APIs | +| `authorizer_proxy_api_call_duration_seconds` | Histogram | endpoint, status | Downstream API latency | + +Endpoints tracked include: +- `/user/profile` - User context lookup from CWMS Data API + +### Authorization Decision Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_proxy_authorization_decisions_total` | Counter | result | Authorization outcomes (allow/deny/error) | + +## Querying Metrics + +### Cache Hit Rate + +Calculate the user context cache hit rate: + +```promql +sum(rate(authorizer_proxy_cache_hits_total{cache_type="user_context"}[5m])) / +(sum(rate(authorizer_proxy_cache_hits_total{cache_type="user_context"}[5m])) + + sum(rate(authorizer_proxy_cache_misses_total{cache_type="user_context"}[5m]))) +``` + +### Request Latency Percentiles + +Get the 95th percentile request latency: + +```promql +histogram_quantile(0.95, rate(authorizer_proxy_http_request_duration_seconds_bucket[5m])) +``` + +### OPA Evaluation Time + +Average OPA evaluation time by decision: + +```promql +rate(authorizer_proxy_opa_evaluation_duration_seconds_sum[5m]) / +rate(authorizer_proxy_opa_evaluation_duration_seconds_count[5m]) +``` + +### Error Rate + +Percentage of requests returning 5xx errors: + +```promql +sum(rate(authorizer_proxy_http_requests_total{status_code=~"5.."}[5m])) / +sum(rate(authorizer_proxy_http_requests_total[5m])) +``` + +## Grafana Dashboard + +For visualization, import these metrics into Grafana. A sample dashboard configuration might include: + +| Panel | Query | Visualization | +|-------|-------|---------------| +| Request Rate | `sum(rate(authorizer_proxy_http_requests_total[1m]))` | Time series | +| Latency p95 | `histogram_quantile(0.95, rate(authorizer_proxy_http_request_duration_seconds_bucket[1m]))` | Time series | +| Cache Hit Rate | See formula above | Gauge (0-100%) | +| Authorization Decisions | `sum by (result)(rate(authorizer_proxy_authorization_decisions_total[5m]))` | Pie chart | + +## Alerting Rules + +Example Prometheus alerting rules: + +```yaml +groups: + - name: authorizer-proxy + rules: + - alert: HighLatency + expr: histogram_quantile(0.95, rate(authorizer_proxy_http_request_duration_seconds_bucket[5m])) > 0.5 + for: 5m + labels: + severity: warning + annotations: + summary: "Authorization proxy p95 latency above 500ms" + + - alert: LowCacheHitRate + expr: | + sum(rate(authorizer_proxy_cache_hits_total[5m])) / + (sum(rate(authorizer_proxy_cache_hits_total[5m])) + + sum(rate(authorizer_proxy_cache_misses_total[5m]))) < 0.8 + for: 10m + labels: + severity: warning + annotations: + summary: "Cache hit rate below 80%" + + - alert: HighErrorRate + expr: | + sum(rate(authorizer_proxy_http_requests_total{status_code=~"5.."}[5m])) / + sum(rate(authorizer_proxy_http_requests_total[5m])) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "Error rate above 5%" +``` + diff --git a/docs/source/access-management/performance/running-benchmarks.md b/docs/source/access-management/performance/running-benchmarks.md new file mode 100644 index 000000000..3b5f77bd8 --- /dev/null +++ b/docs/source/access-management/performance/running-benchmarks.md @@ -0,0 +1,169 @@ +# Running Benchmarks + +The authorization proxy includes a benchmark suite built with [k6](https://k6.io/). These tests simulate realistic traffic patterns to measure latency, throughput, and cache effectiveness. + +## Prerequisites + +You will need k6 installed on your machine. On macOS: + +```bash +brew install k6 +``` + +On other platforms, see the [k6 installation guide](https://grafana.com/docs/k6/latest/set-up/install-k6/). + +The benchmark assumes all services are running via Podman or Docker Compose: + +```bash +cd cwms-access-management +podman compose -f docker-compose.podman.yml up -d +``` + +Verify services are healthy: + +```bash +curl http://localhost:3001/health +curl http://localhost:8080/auth/realms/cwms +``` + +## Quick Benchmark + +For a quick sanity check, run the 30-second benchmark: + +```bash +cd cwms-access-management/tools/benchmark +k6 run quick-benchmark.js +``` + +This runs 10 virtual users making a mix of requests: +- Health checks (baseline latency) +- Authenticated requests through the proxy +- Direct authorization endpoint calls + +The quick benchmark authenticates against Keycloak using the test user credentials, so it exercises the full authorization flow. + +## Full Benchmark Suite + +The full suite runs five scenarios sequentially, taking approximately three minutes: + +```bash +cd cwms-access-management/tools/benchmark +./run-benchmark.sh +``` + +Or run it directly: + +```bash +k6 run scenarios.js +``` + +### Test Scenarios + +| Scenario | Duration | VUs | What It Tests | +|----------|----------|-----|---------------| +| Public Endpoints | 30s | 10 | Baseline proxy overhead on `/health` and `/ready` | +| Warm Cache | 30s | 20 | Repeated requests with same user (cache hits) | +| Cold Cache | 50 requests | 10 | Unique queries forcing cache misses | +| Authorization Endpoint | 30s | 15 | Direct `/authorize` API without proxying | +| Stress Test | 60s | 5-50 | Ramping load to find breaking point | + +## Environment Configuration + +The benchmarks read configuration from environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PROXY_URL` | `http://localhost:3001` | Authorization proxy address | +| `KEYCLOAK_URL` | `http://localhost:8080` | Keycloak server address | +| `KEYCLOAK_REALM` | `cwms` | OAuth realm name | +| `KEYCLOAK_CLIENT_ID` | `cwms` | OAuth client ID | + +To run against a different environment: + +```bash +PROXY_URL=http://staging-proxy:3001 \ +KEYCLOAK_URL=http://staging-auth:8080 \ +k6 run scenarios.js +``` + +## Test Users + +The benchmarks use three test accounts with different permission levels: + +| User Key | Username | Office | Purpose | +|----------|----------|--------|---------| +| `damOperator` | `m5hectest` | SWT | Primary test user, CWMS Users role | +| `waterManager` | `l2hectest` | SPK | Tests cross-office access | +| `viewerUser` | `l1hectest` | SPL | Tests limited permissions | + +These users must exist in both Keycloak and the CWMS database. The Docker Compose setup configures them automatically. + +## Reading Results + +After running, k6 outputs a summary like: + +``` + checks.........................: 99.85% 10882 out 10898 + http_req_duration..............: avg=5.72ms p(95)=12.4ms + http_req_failed................: 0.00% 0 out of 10940 + authorization_latency..........: avg=3.1ms p(95)=8ms + cache_hit_rate.................: 98.50% +``` + +Key metrics to watch: + +| Metric | Good | Concerning | +|--------|------|------------| +| `http_req_duration` p95 | <100ms | >500ms | +| `http_req_failed` | 0% | >1% | +| `cache_hit_rate` | >95% | <80% | + +## Collecting Prometheus Metrics + +The proxy exposes metrics at `/metrics` that can be scraped during the benchmark: + +```bash +# Before running benchmark +curl http://localhost:3001/metrics > before.txt + +# Run benchmark +k6 run quick-benchmark.js + +# After running benchmark +curl http://localhost:3001/metrics > after.txt +``` + +For JSON format (easier to parse): + +```bash +curl http://localhost:3001/metrics/json | jq +``` + +## Troubleshooting + +### Token Acquisition Fails + +If you see `invalid_client` errors, verify the OAuth client exists in Keycloak: + +```bash +curl -X POST "http://localhost:8080/auth/realms/cwms/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=m5hectest&password=m5hectest&grant_type=password&client_id=cwms" +``` + +### Connection Refused + +Ensure all services are running: + +```bash +podman ps | grep -E 'authorizer-proxy|auth|opa|redis' +``` + +### High Error Rates + +Check proxy logs for details: + +```bash +podman logs -f authorizer-proxy +``` + diff --git a/docs/source/access-management/policies/helpers.md b/docs/source/access-management/policies/helpers.md new file mode 100644 index 000000000..26e56d6c9 --- /dev/null +++ b/docs/source/access-management/policies/helpers.md @@ -0,0 +1,346 @@ +# Helper Functions + +Helper modules provide reusable functions for common authorization checks. These modules encapsulate complex logic for office hierarchy validation and time-based access rules. + +## Office Hierarchy Helper + +Location: `policies/helpers/offices.rego` + +The office helper manages hierarchical office relationships and determines if a user can access data from a specific office. + +### Office Data Structure + +Offices are organized in a hierarchy with metadata: + +```rego +offices := { + "HQ": { + "type": "headquarters", + "parent": null, + "region": "headquarters" + }, + "SPK": { + "type": "district", + "parent": "SPD", + "region": "south_pacific", + "timezone": "America/Los_Angeles" + }, + "SWT": { + "type": "district", + "parent": "SWD", + "region": "southwestern", + "timezone": "America/Chicago" + } +} +``` + +### Regional Organization + +Districts are grouped by region with their parent division: + +| Region | Division | Districts | +|--------|----------|-----------| +| South Pacific | SPD | SPK, SPN, SPL | +| Southwestern | SWD | SWT, SPA, GAL, FTW | +| Mississippi | MVD | MVR, MVS, MVP, MVM, MVN | + +```rego +regions := { + "southwestern": { + "division": "SWD", + "districts": ["SWT", "SPA", "GAL", "FTW"] + }, + "south_pacific": { + "division": "SPD", + "districts": ["SPK", "SPN", "SPL"] + }, + "mississippi": { + "division": "MVD", + "districts": ["MVR", "MVS", "MVP", "MVM", "MVN"] + } +} +``` + +### Office Access Rules + +The `user_can_access_office` function evaluates multiple conditions: + +```mermaid +graph TD + canAccess[user_can_access_office] --> inOffices{Office in user.offices?} + inOffices -->|Yes| allow[Allow] + inOffices -->|No| isDataMgr{User is data_manager with region?} + isDataMgr -->|Yes| inRegion{Office in user's region?} + inRegion -->|Yes| allow + inRegion -->|No| isProcessor + isDataMgr -->|No| isProcessor{User is automated_processor?} + isProcessor -->|Yes| allow + isProcessor -->|No| isSysAdmin{User has system_admin role?} + isSysAdmin -->|Yes| allow + isSysAdmin -->|No| isHecEmployee{User has hec_employee role?} + isHecEmployee -->|Yes| allow + isHecEmployee -->|No| deny[Deny] +``` + +### Access Rule Details + +**Direct Office Assignment**: + +```rego +user_can_access_office(user, office_id) if { + office_id in user.offices +} +``` + +User has explicit access to offices listed in their `offices` array. + +**Regional Access (Data Managers)**: + +```rego +user_can_access_office(user, office_id) if { + user.persona == "data_manager" + user.region != null + office_id in regions[user.region].districts +} +``` + +Data managers with a region assignment can access all districts within that region. + +**Cross-Regional Access (Automated Processors)**: + +```rego +user_can_access_office(user, office_id) if { + user.persona == "automated_processor" +} +``` + +Automated processors can access all offices for cross-regional data processing. + +**Privileged Role Access**: + +```rego +user_can_access_office(user, office_id) if { + "system_admin" in user.roles +} + +user_can_access_office(user, office_id) if { + "hec_employee" in user.roles +} +``` + +System admins and HEC employees have unrestricted office access. + +## Time Rules Helper + +Location: `policies/helpers/time_rules.rego` + +The time rules helper manages embargo periods, shift-based access, and modification windows. + +### Embargo System + +Embargo rules restrict access to recent data to prevent premature release. + +**Exempt Personas**: + +```rego +embargo_exempt_personas := ["data_manager", "water_manager", "system_admin", "hec_employee"] +``` + +| Persona | Embargo Exempt | +|---------|----------------| +| data_manager | Yes | +| water_manager | Yes | +| system_admin | Yes | +| hec_employee | Yes | +| All others | No | + +**Exemption Check**: + +```rego +user_embargo_exempt(user) if { + user.persona in embargo_exempt_personas +} +``` + +### TS Group Embargo + +Embargo periods are configured per time series group, allowing fine-grained control: + +```mermaid +graph TD + checkEmbargo[data_under_ts_group_embargo] --> isExempt{User embargo exempt?} + isExempt -->|Yes| notEmbargoed[Not Embargoed] + isExempt -->|No| hasTimestamp{Resource has timestamp?} + hasTimestamp -->|No| notEmbargoed + hasTimestamp -->|Yes| hasTsGroup{Resource has ts_group_id?} + hasTsGroup -->|No| notEmbargoed + hasTsGroup -->|Yes| getHours[Get embargo hours for ts_group] + getHours --> hoursPositive{Embargo hours > 0?} + hoursPositive -->|No| notEmbargoed + hoursPositive -->|Yes| inWindow{Data within embargo window?} + inWindow -->|No| notEmbargoed + inWindow -->|Yes| embargoed[Embargoed] +``` + +**TS Group Embargo Lookup**: + +```rego +get_ts_group_embargo_hours(user, ts_group_id) := hours if { + priv := user.ts_privileges[_] + priv.ts_group_id == ts_group_id + hours := priv.embargo_hours +} + +get_ts_group_embargo_hours(user, ts_group_id) := 168 if { + not ts_group_in_privileges(user, ts_group_id) +} +``` + +The function returns: +- Configured embargo hours if the TS group is in the user's privileges +- Default of 168 hours (7 days) if not found + +**Embargo Check**: + +```rego +data_under_ts_group_embargo(resource, user) if { + not user_embargo_exempt(user) + resource.timestamp_ns != null + resource.ts_group_id != null + embargo_hours := get_ts_group_embargo_hours(user, resource.ts_group_id) + embargo_hours > 0 + embargo_ns := embargo_hours * 60 * 60 * 1000000000 + time.now_ns() - resource.timestamp_ns < embargo_ns +} +``` + +### Legacy Office-Based Embargo + +A legacy system exists for backward compatibility with office-based embargo periods: + +| Office | Embargo Period | +|--------|----------------| +| SPK | 7 days | +| SWT | 3 days | +| DEFAULT | 7 days | + +```rego +embargo_periods := { + "SPK": 7 * 24 * 60 * 60 * 1000000000, + "SWT": 3 * 24 * 60 * 60 * 1000000000, + "DEFAULT": 7 * 24 * 60 * 60 * 1000000000 +} + +data_under_embargo(resource, user) if { + not user.persona in embargo_exempt_personas + resource.timestamp_ns != null + embargo_period := object.get(embargo_periods, resource.office, embargo_periods.DEFAULT) + time.now_ns() - resource.timestamp_ns < embargo_period +} +``` + +### Shift Hours + +Dam operators can only create or update data during their assigned shift hours. + +```rego +within_shift_hours(user) if { + user.persona == "dam_operator" + user.shift_start != null + user.shift_end != null + user.timezone != null + + current_hour := time.clock([time.now_ns(), user.timezone])[0] + current_hour >= user.shift_start + current_hour < user.shift_end +} + +within_shift_hours(user) if { + user.persona != "dam_operator" +} +``` + +**Evaluation Logic**: + +```mermaid +graph TD + checkShift[within_shift_hours] --> isDamOp{User is dam_operator?} + isDamOp -->|No| allowNotApplicable[Allow - not applicable] + isDamOp -->|Yes| hasStart{shift_start defined?} + hasStart -->|No| denyIncomplete[Deny - incomplete config] + hasStart -->|Yes| hasEnd{shift_end defined?} + hasEnd -->|No| denyIncomplete + hasEnd -->|Yes| hasTz{timezone defined?} + hasTz -->|No| denyIncomplete + hasTz -->|Yes| getCurrentHour[Get current hour in user timezone] + getCurrentHour --> afterStart{current_hour >= shift_start?} + afterStart -->|No| denyIncomplete + afterStart -->|Yes| beforeEnd{current_hour < shift_end?} + beforeEnd -->|No| denyIncomplete + beforeEnd -->|Yes| allowNotApplicable +``` + +**User Configuration Example**: + +```json +{ + "persona": "dam_operator", + "shift_start": 6, + "shift_end": 18, + "timezone": "America/Chicago" +} +``` + +This configuration allows operations between 6:00 AM and 6:00 PM Central Time. + +### Modification Window + +Dam operators can only update data within 24 hours of its creation: + +```rego +within_modification_window(resource, user) if { + user.persona == "dam_operator" + resource.created_ns != null + time.now_ns() - resource.created_ns < 24 * 60 * 60 * 1000000000 +} +``` + +| Constraint | Window | +|------------|--------| +| Creation to modification | 24 hours | +| Calculation | `current_time - created_time < 24h` | + +## Helper Usage in Personas + +Personas import and use helpers for authorization decisions: + +```rego +package cwms.personas.dam_operator + +import data.cwms.helpers.offices +import data.cwms.helpers.time_rules + +allow if { + "dam_operator" in input.user.roles + input.action == "read" + input.resource in ["timeseries", "measurements", "levels", "gates", "locations"] + offices.user_can_access_office(input.user, input.context.office_id) + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} +``` + +## Configuration Loading + +The office hierarchy and regions are currently defined statically in the policy. Future implementations may load this data dynamically from: + +- OPA Data API (`PUT /v1/data/cwms/offices`) +- CDA API queries +- Configuration files mounted at startup + +```rego +# In production, load data dynamically from: +# - OPA Data API: curl -X PUT http://localhost:8181/v1/data/cwms/offices -d @offices.json +# - Database Query: Query CDA API to get current office list +# - Configuration File: Load from config/offices.json at startup +``` + diff --git a/docs/source/access-management/policies/index.md b/docs/source/access-management/policies/index.md new file mode 100644 index 000000000..bae8c04eb --- /dev/null +++ b/docs/source/access-management/policies/index.md @@ -0,0 +1,159 @@ +# Policy System Overview + +The CWMS Access Management system uses Open Policy Agent (OPA) to evaluate authorization decisions. This document describes the policy architecture, evaluation flow, and how policies interact with the authorization proxy. + +## Policy Architecture + +The policy system consists of three main components: + +1. **Main Orchestrator** (`cwms_authz.rego`) - Entry point that evaluates all persona policies +2. **Persona Policies** - Role-specific authorization rules in the `personas/` directory +3. **Helper Modules** - Reusable functions for office hierarchy and time-based rules + +```mermaid +graph TD + A[Authorization Request] --> B[cwms_authz.rego] + B --> C{Evaluate Personas} + C --> D[public] + C --> E[dam_operator] + C --> F[water_manager] + C --> G[data_manager] + C --> H[automated_collector] + C --> I[automated_processor] + C --> J[external_cooperator] + C --> K[viewer_users] + D --> L{Any Allow?} + E --> L + F --> L + G --> L + H --> L + I --> L + J --> L + K --> L + L -->|Yes| M[allow: true] + L -->|No| N[allow: false] +``` + +## Policy Evaluation + +### Input Structure + +OPA policies receive a standardized input structure from the authorization proxy: + +```json +{ + "user": { + "id": "m5hectest", + "roles": ["dam_operator", "cwms_user"], + "offices": ["SWT"], + "persona": "dam_operator", + "timezone": "America/Chicago", + "ts_privileges": [ + {"ts_group_id": "PRECIP", "embargo_hours": 72} + ] + }, + "action": "read", + "resource": "timeseries", + "context": { + "office_id": "SWT", + "classification": "public", + "data_source": "AUTOMATED", + "ts_group_id": "PRECIP", + "timestamp_ns": 1705708800000000000 + } +} +``` + +### Evaluation Order + +The main orchestrator (`cwms_authz.rego`) evaluates persona policies in sequence. Authorization succeeds if any persona policy returns `allow = true`: + +```rego +default allow := false + +allow if { public.allow } +allow if { dam_operator.allow } +allow if { water_manager.allow } +allow if { data_manager.allow } +allow if { automated_collector.allow } +allow if { automated_processor.allow } +allow if { external_cooperator.allow } +allow if { viewer_users.allow } +``` + +### Privileged Roles + +Two roles bypass persona-based evaluation entirely: + +| Role | Description | +|------|-------------| +| `system_admin` | Full system access, bypasses all persona checks | +| `hec_employee` | HEC staff access, bypasses all persona checks | + +```rego +allow if { "system_admin" in input.user.roles } +allow if { "hec_employee" in input.user.roles } +``` + +## Policy Decision Response + +OPA returns a decision that the authorization proxy uses to construct the `x-cwms-auth-context` header: + +```json +{ + "allow": true, + "decision_id": "opa-12345", + "constraints": { + "allowed_offices": ["SWT"], + "embargo_rules": {"SWT": 72, "default": 168}, + "embargo_exempt": false + } +} +``` + +## Helper Module Integration + +Persona policies import helper modules to evaluate complex conditions: + +```rego +import data.cwms.helpers.offices +import data.cwms.helpers.time_rules +``` + +Helpers provide: + +- **Office Validation** - Checks if user can access a specific office based on assignments, region, or role +- **Time Rules** - Evaluates embargo periods, shift hours, and modification windows + +See [helpers.md](helpers.md) for detailed documentation. + +## Policy File Organization + +``` +policies/ + cwms_authz.rego # Main orchestrator + personas/ + public.rego # Unauthenticated/public access + dam_operator.rego # Dam operators + water_manager.rego # Water managers + data_manager.rego # Data managers + automated_collector.rego # Automated data collection + automated_processor.rego # Automated data processing + external_cooperator.rego # External partners + viewer_users.rego # Read-only users + helpers/ + offices.rego # Office hierarchy and access + time_rules.rego # Embargo and time window rules +``` + +## Related Documentation + +- [Persona Policies](personas.md) - Detailed documentation for each persona +- [Helper Functions](helpers.md) - Office hierarchy and time rule helpers + +```{toctree} +:maxdepth: 2 + +personas +helpers +``` diff --git a/docs/source/access-management/policies/personas.md b/docs/source/access-management/policies/personas.md new file mode 100644 index 000000000..e5f9cf174 --- /dev/null +++ b/docs/source/access-management/policies/personas.md @@ -0,0 +1,457 @@ +# Persona Policies + +Each persona represents a distinct user role with specific access patterns and constraints. The policy system evaluates the user's roles against these persona definitions to determine authorization. + +## Persona Summary + +| Persona | Role Identifier | Read | Create | Update | Delete | Embargo Exempt | +|---------|-----------------|------|--------|--------|--------|----------------| +| Public | (none required) | Limited | No | No | No | No | +| Dam Operator | `dam_operator` | Yes | Manual only | Manual only | No | No | +| Water Manager | `water_manager` | Yes | Yes | Yes | No | Yes | +| Data Manager | `data_manager` | Yes | Yes | Yes | Approved only | Yes | +| Automated Collector | `automated_collector` | No | Automated only | No | No | No | +| Automated Processor | `automated_processor` | Yes | Calculated only | Calculated only | No | No | +| External Cooperator | `external_cooperator` | Limited | Limited | No | No | No | +| Viewer Users | `Viewer Users` | Yes | No | No | No | No | + +## Public Access + +The public persona allows unauthenticated access to non-sensitive endpoints. + +### Allowed Operations + +**System Endpoints** (no authentication required): + +- `health`, `ready`, `metrics` + +**Reference Data** (read-only): + +- `offices`, `units`, `parameters`, `timezones` + +**Public Timeseries Data**: + +- Classification must be `public` +- Subject to TS group embargo rules + +**Public Locations**: + +- Classification must be `public` + +### Policy Logic + +```rego +package cwms.personas.public + +allow if { + input.resource in ["health", "ready", "metrics"] +} + +allow if { + input.action == "read" + input.resource in ["offices", "units", "parameters", "timezones"] +} + +allow if { + input.action == "read" + input.resource == "timeseries" + input.context.classification == "public" + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} + +allow if { + input.action == "read" + input.resource == "locations" + input.context.classification == "public" +} +``` + +## Dam Operator + +Dam operators monitor and manually enter operational data for their assigned facilities. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices only | +| Embargo | Subject to TS group embargo | +| Shift Hours | Create/update restricted to shift hours | +| Modification Window | Updates limited to 24 hours after creation | +| Data Source | Manual entries only | + +### Allowed Operations + +**Read Access**: + +- `timeseries`, `measurements`, `levels`, `gates`, `locations` +- Must have office access +- Subject to embargo rules + +**Create Access**: + +- `timeseries`, `measurements` +- Data source must be `MANUAL` +- Must be within shift hours +- Must have office access + +**Update Access**: + +- `timeseries`, `measurements` +- Data source must be `MANUAL` +- Must be within shift hours +- Resource must be within 24-hour modification window +- Must have office access + +### Policy Logic + +```rego +package cwms.personas.dam_operator + +allow if { + "dam_operator" in input.user.roles + input.action == "read" + input.resource in ["timeseries", "measurements", "levels", "gates", "locations"] + offices.user_can_access_office(input.user, input.context.office_id) + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} + +allow if { + "dam_operator" in input.user.roles + input.action == "create" + input.resource in ["timeseries", "measurements"] + input.context.data_source == "MANUAL" + offices.user_can_access_office(input.user, input.context.office_id) + time_rules.within_shift_hours(input.user) +} + +allow if { + "dam_operator" in input.user.roles + input.action == "update" + input.resource in ["timeseries", "measurements"] + input.context.data_source == "MANUAL" + offices.user_can_access_office(input.user, input.context.office_id) + time_rules.within_shift_hours(input.user) + time_rules.within_modification_window(input.context, input.user) +} +``` + +## Water Manager + +Water managers have broad access for hydrological analysis and forecasting within their assigned offices. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices only | +| Embargo | Exempt | +| Data Classification | All levels | + +### Allowed Operations + +**Read Access**: + +- `timeseries`, `locations`, `forecasts`, `models`, `scenarios`, `ratings`, `levels` +- Must have office access + +**Create/Update Access**: + +- `timeseries`, `locations`, `forecasts`, `models`, `scenarios` +- Must have office access + +### Policy Logic + +```rego +package cwms.personas.water_manager + +allow if { + "water_manager" in input.user.roles + input.action == "read" + input.resource in ["timeseries", "locations", "forecasts", "models", "scenarios", "ratings", "levels"] + offices.user_can_access_office(input.user, input.context.office_id) +} + +allow if { + "water_manager" in input.user.roles + input.action in ["create", "update"] + input.resource in ["timeseries", "locations", "forecasts", "models", "scenarios"] + offices.user_can_access_office(input.user, input.context.office_id) +} +``` + +## Data Manager + +Data managers have full data lifecycle control with approval workflows for destructive operations. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices and regional offices | +| Embargo | Exempt | +| Delete | Requires approval from different user | + +### Allowed Operations + +**Read/Create/Update Access**: + +- `timeseries`, `locations`, `catalogs`, `ratings`, `measurements`, `levels` +- Must have office access (direct or regional) + +**Delete Access**: + +- `timeseries`, `locations`, `catalogs`, `ratings` +- Must have office access +- Requires `approval_status == "approved"` +- Approver must be different from requester + +### Policy Logic + +```rego +package cwms.personas.data_manager + +allow if { + "data_manager" in input.user.roles + input.action in ["read", "create", "update"] + input.resource in ["timeseries", "locations", "catalogs", "ratings", "measurements", "levels"] + offices.user_can_access_office(input.user, input.context.office_id) +} + +allow if { + "data_manager" in input.user.roles + input.action == "delete" + input.resource in ["timeseries", "locations", "catalogs", "ratings"] + offices.user_can_access_office(input.user, input.context.office_id) + input.context.approval_status == "approved" + input.context.approver_id != input.user.id +} +``` + +## Automated Collector + +Service accounts for automated data collection systems. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices only | +| Authentication | API key required | +| Data Source | Automated only | + +### Allowed Operations + +**Create Access**: + +- `timeseries` only +- Data source must be `AUTOMATED` +- Authentication method must be `api_key` +- Must have office access + +### Policy Logic + +```rego +package cwms.personas.automated_collector + +allow if { + "automated_collector" in input.user.roles + input.action == "create" + input.resource == "timeseries" + input.context.data_source == "AUTOMATED" + offices.user_can_access_office(input.user, input.context.office_id) + input.user.auth_method == "api_key" +} +``` + +## Automated Processor + +Service accounts for data processing pipelines that read raw data and write calculated results. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | All offices (cross-regional processing) | +| Embargo | Subject to TS group embargo for reads | +| Data Source | Read: AUTOMATED/MANUAL, Write: CALCULATED only | + +### Allowed Operations + +**Read Access**: + +- `timeseries` only +- Data source must be `AUTOMATED` or `MANUAL` +- Subject to embargo rules +- No office restriction (cross-regional processing) + +**Create/Update Access**: + +- `timeseries` only +- Data source must be `CALCULATED` +- Must include `calculation_metadata` + +### Policy Logic + +```rego +package cwms.personas.automated_processor + +allow if { + "automated_processor" in input.user.roles + input.action == "read" + input.resource == "timeseries" + input.context.data_source in ["AUTOMATED", "MANUAL"] + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} + +allow if { + "automated_processor" in input.user.roles + input.action in ["create", "update"] + input.resource == "timeseries" + input.context.data_source == "CALCULATED" + input.context.calculation_metadata != null +} +``` + +## External Cooperator + +External partners with limited access governed by partnership agreements. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices only | +| Partnership | Must have active (non-expired) partnership | +| Parameters | Limited to allowed parameter types | +| Classification | Cannot access sensitive data | +| Embargo | Subject to TS group embargo | + +### Allowed Operations + +**Read Access**: + +- `timeseries` only +- Parameter must be in user's `allowed_parameters` list +- Classification cannot be `sensitive` +- Partnership must be active (not expired) +- Subject to embargo rules + +**Create Access**: + +- `timeseries` only +- Parameter must be in user's `allowed_parameters` list +- Must have office access +- Partnership must be active + +### Policy Logic + +```rego +package cwms.personas.external_cooperator + +partnership_active(user) if { + user.partnership_expiry_ns != null + user.partnership_expiry_ns > time.now_ns() +} + +allow if { + "external_cooperator" in input.user.roles + input.action == "read" + input.resource == "timeseries" + input.context.parameter in input.user.allowed_parameters + input.context.classification != "sensitive" + partnership_active(input.user) + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} + +allow if { + "external_cooperator" in input.user.roles + input.action == "create" + input.resource == "timeseries" + input.context.parameter in input.user.allowed_parameters + offices.user_can_access_office(input.user, input.context.office_id) + partnership_active(input.user) +} +``` + +## Viewer Users + +Read-only users with basic access to data within their assigned offices. + +### Constraints + +| Constraint | Value | +|------------|-------| +| Office Access | Assigned offices only | +| Embargo | Subject to TS group embargo | +| Actions | Read-only | + +### Allowed Operations + +**Reference Data** (read-only, no office restriction): + +- `offices`, `units`, `parameters`, `timezones` + +**Operational Data** (read-only): + +- `timeseries`, `locations`, `levels` +- Must have office access +- Subject to embargo rules + +### Policy Logic + +```rego +package cwms.personas.viewer_users + +allow if { + "Viewer Users" in input.user.roles + input.action == "read" + input.resource in ["offices", "units", "parameters", "timezones"] +} + +allow if { + "Viewer Users" in input.user.roles + input.action == "read" + input.resource in ["timeseries", "locations", "levels"] + offices.user_can_access_office(input.user, input.context.office_id) + not time_rules.data_under_ts_group_embargo(input.context, input.user) +} +``` + +## Policy Matching Logic + +When a request arrives, the main orchestrator evaluates each persona policy. The first matching rule grants access: + +```mermaid +graph TD + request[Request with User Roles] --> checkSysAdmin{Check system_admin} + checkSysAdmin -->|Yes| allow[Allow] + checkSysAdmin -->|No| checkHec{Check hec_employee} + checkHec -->|Yes| allow + checkHec -->|No| evalPersonas{Evaluate Persona Policies} + evalPersonas --> matchRoles[Match user.roles to persona] + matchRoles --> roleMatch{Role matches?} + roleMatch -->|No| nextPersona[Next persona] + nextPersona --> evalPersonas + roleMatch -->|Yes| checkAction{Check action} + checkAction -->|Invalid| nextPersona + checkAction -->|Valid| checkResource{Check resource} + checkResource -->|Invalid| nextPersona + checkResource -->|Valid| checkConstraints{Check constraints} + checkConstraints -->|Fail| nextPersona + checkConstraints -->|Pass| allow + evalPersonas -->|All exhausted| deny[Deny] +``` + +## Constraint Comparison + +| Constraint | Public | Dam Op | Water Mgr | Data Mgr | Auto Collect | Auto Process | Ext Coop | Viewer | +|------------|--------|--------|-----------|----------|--------------|--------------|----------|--------| +| Embargo Exempt | No | No | Yes | Yes | N/A | No | No | No | +| Office Restricted | No | Yes | Yes | Yes | Yes | No | Yes | Yes | +| Shift Hours | No | Yes | No | No | No | No | No | No | +| Mod Window | No | Yes | No | No | No | No | No | No | +| API Key Required | No | No | No | No | Yes | No | No | No | +| Partnership Required | No | No | No | No | No | No | Yes | No | +| Delete Approval | N/A | N/A | N/A | Yes | N/A | N/A | N/A | N/A | + diff --git a/docs/source/access-management/proxy-api/authorize-endpoint.md b/docs/source/access-management/proxy-api/authorize-endpoint.md new file mode 100644 index 000000000..bd834519c --- /dev/null +++ b/docs/source/access-management/proxy-api/authorize-endpoint.md @@ -0,0 +1,264 @@ +# POST /authorize Endpoint + +The `/authorize` endpoint provides authorization decisions for external services. It evaluates whether a user is allowed to perform a specific action on a resource based on OPA policies. + +## Request + +### URL + +``` +POST /authorize +``` + +### Headers + +| Header | Required | Description | +|--------|----------|-------------| +| `Content-Type` | Yes | Must be `application/json` | + +### Request Body + +```json +{ + "resource": "timeseries", + "action": "read", + "user": { + "id": "m5hectest", + "username": "m5hectest", + "roles": ["CWMS Users", "TS ID Creator"], + "offices": ["SWT"], + "persona": "operator", + "shift_start": 6, + "shift_end": 18, + "timezone": "America/Chicago" + }, + "context": { + "office_id": "SWT", + "data_source": "USGS" + } +} +``` + +### Body Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `resource` | string | Yes | Resource being accessed (e.g., `timeseries`, `locations`, `offices`) | +| `action` | string | Yes | Action being performed: `read`, `create`, `update`, `delete` | +| `user` | object | No | User context object (alternative to `jwt_token`) | +| `context` | object | No | Additional context for authorization decision | +| `jwt_token` | string | No | JWT token for user authentication (alternative to `user` object) | + +### User Object Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | User identifier | +| `username` | string | Username | +| `roles` | array | List of user roles | +| `offices` | array | List of offices the user belongs to | +| `persona` | string | Active persona (e.g., `operator`, `analyst`) | +| `shift_start` | number | Shift start hour (0-23) | +| `shift_end` | number | Shift end hour (0-23) | +| `timezone` | string | User timezone (IANA format) | + +### Context Object Fields + +| Field | Type | Description | +|-------|------|-------------| +| `office_id` | string | Office ID for the requested data | +| `data_source` | string | Data source identifier | +| `created_ns` | number | Creation timestamp in nanoseconds | +| `timestamp_ns` | number | Data timestamp in nanoseconds | + +Additional fields can be included as needed by the OPA policy. + +## Response + +### Success Response (200 OK) + +```json +{ + "decision": { + "allow": true, + "decision_id": "proxy-a1b2c3d4", + "reason": "User has read access to timeseries in office SWT" + }, + "user": { + "id": "m5hectest", + "username": "m5hectest", + "email": "m5hectest@example.com", + "roles": ["CWMS Users", "TS ID Creator"], + "offices": ["SWT"], + "primary_office": "SWT", + "persona": "operator" + }, + "constraints": { + "allowed_offices": ["SWT"], + "embargo_rules": { + "SWT": 72, + "default": 168 + }, + "embargo_exempt": false, + "time_window": { + "restrict_hours": 8 + }, + "data_classification": ["public", "internal"] + }, + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `decision.allow` | boolean | Whether the action is allowed | +| `decision.decision_id` | string | Unique identifier for this decision (for audit logging) | +| `decision.reason` | string | Human-readable explanation of the decision | +| `user` | object | Resolved user information | +| `constraints` | object | Data filtering constraints to apply | +| `timestamp` | string | ISO 8601 timestamp of the decision | + +### Constraints Object + +| Field | Type | Description | +|-------|------|-------------| +| `allowed_offices` | array | Offices the user can access, or `["*"]` for all | +| `embargo_rules` | object | Hours of embargo per office (data newer than X hours restricted) | +| `embargo_exempt` | boolean | Whether user is exempt from embargo rules | +| `time_window` | object | Time window restrictions for historical data | +| `data_classification` | array | Classification levels the user can access | + +### Error Responses + +#### 400 Bad Request + +Missing required fields: + +```json +{ + "error": "Bad Request", + "message": "resource and action are required fields" +} +``` + +Invalid action value: + +```json +{ + "error": "Bad Request", + "message": "action must be one of: read, create, update, delete" +} +``` + +#### 500 Internal Server Error + +```json +{ + "error": "Internal Server Error", + "message": "Authorization processing failed" +} +``` + +## Examples + +### Using curl with User Object + +```bash +curl -X POST http://localhost:3001/authorize \ + -H "Content-Type: application/json" \ + -d '{ + "resource": "timeseries", + "action": "read", + "user": { + "id": "m5hectest", + "username": "m5hectest", + "roles": ["CWMS Users"], + "offices": ["SWT"] + }, + "context": { + "office_id": "SWT" + } + }' +``` + +### Using curl with JWT Token + +```bash +curl -X POST http://localhost:3001/authorize \ + -H "Content-Type: application/json" \ + -d '{ + "resource": "timeseries", + "action": "create", + "jwt_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "context": { + "office_id": "SWT" + } + }' +``` + +### Checking Write Permission + +```bash +curl -X POST http://localhost:3001/authorize \ + -H "Content-Type: application/json" \ + -d '{ + "resource": "timeseries", + "action": "update", + "user": { + "id": "m5hectest", + "username": "m5hectest", + "roles": ["CWMS Users", "TS ID Creator"], + "offices": ["SWT"] + }, + "context": { + "office_id": "SWT", + "data_source": "manual" + } + }' +``` + +## Use Cases + +### Pre-flight Authorization Check + +External services can check authorization before attempting an operation: + +```mermaid +sequenceDiagram + participant Client + participant Proxy + participant OPA + + Client->>Proxy: POST /authorize + Proxy->>OPA: Evaluate policy + OPA-->>Proxy: Decision + constraints + Proxy-->>Client: Allow/Deny + constraints + + alt Allowed + Client->>Proxy: Actual API request + else Denied + Client->>Client: Handle denial + end +``` + +### Batch Authorization + +For batch operations, check authorization once and cache the constraints: + +```bash +# Get constraints for the session +CONSTRAINTS=$(curl -s -X POST http://localhost:3001/authorize \ + -H "Content-Type: application/json" \ + -d '{"resource": "timeseries", "action": "read", "jwt_token": "'$TOKEN'"}' \ + | jq -r '.constraints') + +# Use constraints for multiple requests +echo $CONSTRAINTS +``` + +## Related Documentation + +- [Data Filtering](../filtering/index.md) - How constraints affect data filtering +- [Authorization Context Header](../header-format/index.md) - Header format passed to downstream API diff --git a/docs/source/access-management/proxy-api/health-endpoints.md b/docs/source/access-management/proxy-api/health-endpoints.md new file mode 100644 index 000000000..e7fb152ec --- /dev/null +++ b/docs/source/access-management/proxy-api/health-endpoints.md @@ -0,0 +1,257 @@ +# Health Endpoints + +The CWMS Authorization Proxy provides health and readiness endpoints for monitoring and container orchestration. + +## GET /health + +Basic health check endpoint that returns immediately if the service is running. + +### Request + +``` +GET /health +``` + +### Response + +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00.000Z", + "service": "authorizer-proxy" +} +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | Always `healthy` when the service is running | +| `timestamp` | string | ISO 8601 timestamp of the response | +| `service` | string | Service identifier (`authorizer-proxy`) | + +### Example + +```bash +curl http://localhost:3001/health +``` + +Response: + +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00.000Z", + "service": "authorizer-proxy" +} +``` + +## GET /ready + +Readiness check that verifies the proxy can reach the downstream CWMS Data API. + +### Request + +``` +GET /ready +``` + +### Response (Ready) + +```json +{ + "status": "ready", + "downstream": "available", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +### Response (Not Ready) + +```json +{ + "status": "not-ready", + "downstream": "unavailable", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | `ready` or `not-ready` | +| `downstream` | string | `available` or `unavailable` | +| `timestamp` | string | ISO 8601 timestamp of the response | + +### Example + +```bash +curl http://localhost:3001/ready +``` + +## Behavior + +### Health Check + +The `/health` endpoint: +- Returns immediately without external checks +- Always returns 200 OK if the server is accepting connections +- Lightweight check suitable for high-frequency polling + +### Readiness Check + +The `/ready` endpoint: +- Makes a HEAD request to the downstream CWMS Data API +- Uses a 5-second timeout for the downstream check +- Returns `ready` only if the downstream API responds successfully +- Returns `not-ready` if the downstream API is unreachable or returns an error + +```mermaid +flowchart TD + readyRequest[GET /ready] --> headRequest[HEAD request to CWMS API] + headRequest --> responseOk{Response OK?} + responseOk -->|Yes| returnReady[Return ready] + responseOk -->|No| returnNotReady[Return not-ready] + headRequest -->|Timeout/Error| returnNotReady +``` + +## Container Orchestration + +### Kubernetes + +Configure liveness and readiness probes: + +```yaml +apiVersion: v1 +kind: Pod +spec: + containers: + - name: authorizer-proxy + livenessProbe: + httpGet: + path: /health + port: 3001 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 3 +``` + +### Docker Compose + +Configure healthcheck in docker-compose: + +```yaml +services: + authorizer-proxy: + image: cwms-authorizer-proxy:local-dev + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s +``` + +### Podman + +Check container health manually: + +```bash +# Basic health check +podman exec authorizer-proxy curl -sf http://localhost:3001/health + +# Readiness check +podman exec authorizer-proxy curl -sf http://localhost:3001/ready +``` + +## Monitoring Scripts + +### Simple Health Check Script + +```bash +#!/bin/bash +response=$(curl -sf http://localhost:3001/health) +if [ $? -eq 0 ]; then + echo "Proxy is healthy" + exit 0 +else + echo "Proxy is not responding" + exit 1 +fi +``` + +### Readiness Check with Retry + +```bash +#!/bin/bash +max_attempts=30 +attempt=1 + +while [ $attempt -le $max_attempts ]; do + response=$(curl -sf http://localhost:3001/ready) + status=$(echo $response | jq -r '.status') + + if [ "$status" = "ready" ]; then + echo "Proxy is ready" + exit 0 + fi + + echo "Attempt $attempt: Proxy not ready, waiting..." + sleep 2 + attempt=$((attempt + 1)) +done + +echo "Proxy failed to become ready after $max_attempts attempts" +exit 1 +``` + +## Use Cases + +### Startup Sequencing + +Wait for the proxy to be ready before running integration tests: + +```bash +# Wait for proxy to be ready +until curl -sf http://localhost:3001/ready | jq -e '.status == "ready"' > /dev/null; do + echo "Waiting for proxy..." + sleep 2 +done + +echo "Proxy ready, running tests..." +npm test +``` + +### Load Balancer Health Checks + +Configure your load balancer to use the health endpoint: + +| Setting | Value | +|---------|-------| +| Health check path | `/health` | +| Health check interval | 30 seconds | +| Healthy threshold | 2 consecutive successes | +| Unhealthy threshold | 3 consecutive failures | +| Timeout | 5 seconds | + +### Alerting + +Monitor the ready endpoint for downstream issues: + +```bash +# Check every minute and alert if not ready +while true; do + status=$(curl -sf http://localhost:3001/ready | jq -r '.status') + if [ "$status" != "ready" ]; then + echo "ALERT: Proxy not ready - downstream may be unavailable" + fi + sleep 60 +done +``` diff --git a/docs/source/access-management/proxy-api/index.md b/docs/source/access-management/proxy-api/index.md new file mode 100644 index 000000000..eed811b51 --- /dev/null +++ b/docs/source/access-management/proxy-api/index.md @@ -0,0 +1,119 @@ +# Proxy API Overview + +The CWMS Authorization Proxy exposes several API endpoints for authorization decisions, health monitoring, and transparent proxying of CWMS Data API requests. + +## Base URL + +The proxy listens on the configured `HOST` and `PORT` (default: `http://localhost:3001`). + +## Endpoint Categories + +### Authorization Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/authorize` | POST | Get authorization decision for a resource and action | + +### Health Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Basic health check | +| `/ready` | GET | Readiness check including downstream service availability | + +### Proxy Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/cwms-data/*` | ALL | Transparent proxy to CWMS Data API with authorization | + +## Authentication + +### Authorization Endpoint + +The `/authorize` endpoint accepts authentication via: + +1. **JWT Token** - Pass a JWT token in the request body (`jwt_token` field) +2. **User Object** - Pass user context directly in the request body (`user` field) + +### Proxy Endpoints + +Proxied requests to `/cwms-data/*` extract authentication from: + +1. **Authorization Header** - `Authorization: Bearer ` +2. **API Key Header** - `apikey: ` (for service-to-service communication) + +## Request Flow + +```mermaid +flowchart TD + A[Client Request] --> B{Endpoint Type} + B -->|/health, /ready| C[Return Status] + B -->|/authorize| D[Process Authorization] + B -->|/cwms-data/*| E{In Whitelist?} + E -->|Yes| F[OPA Policy Check] + E -->|No| G[Bypass Auth] + F --> H{Allowed?} + H -->|Yes| I[Proxy to CWMS API] + H -->|No| J[Return 403] + G --> I + D --> K[Return Decision] +``` + +## Response Format + +All API responses follow a consistent JSON structure: + +### Success Response + +```json +{ + "field": "value", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +### Error Response + +```json +{ + "error": "Error Type", + "message": "Human-readable error description" +} +``` + +## HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 400 | Bad Request - Invalid input | +| 401 | Unauthorized - Missing or invalid authentication | +| 403 | Forbidden - Authorization denied | +| 404 | Not Found - Endpoint does not exist | +| 500 | Internal Server Error - Unexpected error | +| 502 | Bad Gateway - Downstream service unavailable | + +## CORS Support + +The proxy enables CORS for all origins with the following settings: + +- Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS +- Credentials: Enabled +- All origins allowed (configurable for production) + +## OpenAPI Documentation + +The proxy includes Swagger/OpenAPI documentation available at: + +- Swagger UI: `http://localhost:3001/docs` +- OpenAPI JSON: `http://localhost:3001/docs/json` + +## Endpoint Documentation + +```{toctree} +:maxdepth: 1 + +authorize-endpoint +health-endpoints +``` diff --git a/docs/source/conf.py b/docs/source/conf.py index 029030e68..6c08add2d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -61,7 +61,7 @@ html_theme = "sphinx_rtd_theme" html_theme_options = { - "navigation_depth": 3, + "navigation_depth": 5, "collapse_navigation": False, "includehidden": False, #avoid pulling anchors/hidden items into the sidebar } diff --git a/docs/source/index.rst b/docs/source/index.rst index 3e6674bf0..9b8478994 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -37,6 +37,13 @@ Welcome to CWMS Data API documentation! RFCs <./rfc/index.rst> +.. toctree:: + :maxdepth: 3 + :caption: Access Management + + Access Management <./access-management/index.md> + + .. toctree:: :maxdepth: 1 :caption: Alternative Topics