mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-08-01 16:47:42 +03:00
Initial commit - 611 cybersecurity skills across all subdomains
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: implementing-scim-provisioning-with-okta
|
||||
description: Implement automated user provisioning and deprovisioning using SCIM 2.0 protocol with Okta as the identity provider.
|
||||
domain: cybersecurity
|
||||
subdomain: identity-access-management
|
||||
tags: [scim, okta, provisioning, identity-management, automation, sso, lifecycle-management]
|
||||
version: "1.0"
|
||||
author: mahipal
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Implementing SCIM Provisioning with Okta
|
||||
|
||||
## Overview
|
||||
|
||||
SCIM (System for Cross-domain Identity Management) is an open standard protocol (RFC 7644) that automates the exchange of user identity information between identity providers like Okta and service providers. This skill covers building a SCIM 2.0-compliant API endpoint and integrating it with Okta for automated user lifecycle management including provisioning, deprovisioning, profile updates, and group management.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Okta tenant with admin access (Developer or Production)
|
||||
- Application with REST API capable of user management
|
||||
- TLS-secured endpoint (HTTPS required)
|
||||
- Okta API token or OAuth 2.0 client credentials
|
||||
- Python 3.9+ with Flask or FastAPI
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### SCIM 2.0 Protocol
|
||||
|
||||
SCIM defines a standard schema for representing users and groups via JSON, with a RESTful API for CRUD operations:
|
||||
|
||||
| Operation | HTTP Method | Endpoint | Description |
|
||||
|-----------|-------------|----------|-------------|
|
||||
| Create User | POST | /scim/v2/Users | Provisions a new user account |
|
||||
| Read User | GET | /scim/v2/Users/{id} | Retrieves user details |
|
||||
| Update User | PUT/PATCH | /scim/v2/Users/{id} | Modifies user attributes |
|
||||
| Delete User | DELETE | /scim/v2/Users/{id} | Removes user account |
|
||||
| List Users | GET | /scim/v2/Users | Lists users with filtering |
|
||||
| Create Group | POST | /scim/v2/Groups | Creates a group |
|
||||
| Manage Group | PATCH | /scim/v2/Groups/{id} | Add/remove group members |
|
||||
|
||||
### Okta SCIM Integration Architecture
|
||||
|
||||
```
|
||||
Okta (IdP) ──SCIM 2.0 over HTTPS──> SCIM Server ──> Application Database
|
||||
│ │
|
||||
├── User Assignment ├── Create/Update User
|
||||
├── User Unassignment ├── Deactivate User
|
||||
├── Profile Push ├── Sync Attributes
|
||||
└── Group Push └── Manage Groups
|
||||
```
|
||||
|
||||
### Required SCIM Endpoints
|
||||
|
||||
1. **ServiceProviderConfig** (`/scim/v2/ServiceProviderConfig`): Advertises SCIM capabilities
|
||||
2. **ResourceTypes** (`/scim/v2/ResourceTypes`): Describes supported resource types
|
||||
3. **Schemas** (`/scim/v2/Schemas`): Publishes the SCIM schema definitions
|
||||
4. **Users** (`/scim/v2/Users`): User lifecycle operations
|
||||
5. **Groups** (`/scim/v2/Groups`): Group management operations
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Build SCIM 2.0 API Server
|
||||
|
||||
Create a Flask-based SCIM server that implements the core endpoints. The server must handle:
|
||||
|
||||
- **User CRUD**: Create, read, update, delete, and list users
|
||||
- **Filtering**: Support `eq` filter on `userName` (required by Okta)
|
||||
- **Pagination**: Return `startIndex`, `itemsPerPage`, and `totalResults`
|
||||
- **Authentication**: Bearer token validation on all endpoints
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Bearer token for Okta authentication
|
||||
SCIM_BEARER_TOKEN = "your-secure-token-here"
|
||||
|
||||
def require_auth(f):
|
||||
def wrapper(*args, **kwargs):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer ") or auth[7:] != SCIM_BEARER_TOKEN:
|
||||
return jsonify({"detail": "Unauthorized"}), 401
|
||||
return f(*args, **kwargs)
|
||||
wrapper.__name__ = f.__name__
|
||||
return wrapper
|
||||
|
||||
@app.route("/scim/v2/Users", methods=["POST"])
|
||||
@require_auth
|
||||
def create_user():
|
||||
data = request.json
|
||||
user_id = str(uuid.uuid4())
|
||||
user = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": user_id,
|
||||
"userName": data.get("userName"),
|
||||
"name": data.get("name", {}),
|
||||
"emails": data.get("emails", []),
|
||||
"active": True,
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": datetime.utcnow().isoformat() + "Z",
|
||||
"lastModified": datetime.utcnow().isoformat() + "Z",
|
||||
"location": f"/scim/v2/Users/{user_id}"
|
||||
}
|
||||
}
|
||||
# Persist user to database
|
||||
return jsonify(user), 201
|
||||
|
||||
@app.route("/scim/v2/Users", methods=["GET"])
|
||||
@require_auth
|
||||
def list_users():
|
||||
filter_param = request.args.get("filter", "")
|
||||
start_index = int(request.args.get("startIndex", 1))
|
||||
count = int(request.args.get("count", 100))
|
||||
# Parse filter: userName eq "john@example.com"
|
||||
# Query database with filter
|
||||
return jsonify({
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": 0,
|
||||
"startIndex": start_index,
|
||||
"itemsPerPage": count,
|
||||
"Resources": []
|
||||
})
|
||||
```
|
||||
|
||||
### Step 2: Configure Okta Application
|
||||
|
||||
1. **Create SCIM App Integration**:
|
||||
- Navigate to Okta Admin Console > Applications > Create App Integration
|
||||
- Select SWA or SAML 2.0 as sign-on method
|
||||
- In the General tab, select SCIM for Provisioning
|
||||
|
||||
2. **Configure SCIM Connection**:
|
||||
- SCIM connector base URL: `https://your-app.com/scim/v2`
|
||||
- Unique identifier field: `userName`
|
||||
- Supported provisioning actions: Push New Users, Push Profile Updates, Push Groups
|
||||
- Authentication Mode: HTTP Header (Bearer Token)
|
||||
|
||||
3. **Enable Provisioning Features**:
|
||||
- To App: Create Users, Update User Attributes, Deactivate Users
|
||||
- Configure attribute mappings between Okta profile and SCIM schema
|
||||
|
||||
### Step 3: Map Attributes
|
||||
|
||||
Map Okta user profile attributes to your SCIM schema:
|
||||
|
||||
| Okta Attribute | SCIM Attribute | Direction |
|
||||
|---------------|----------------|-----------|
|
||||
| login | userName | Okta -> App |
|
||||
| firstName | name.givenName | Okta -> App |
|
||||
| lastName | name.familyName | Okta -> App |
|
||||
| email | emails[type eq "work"].value | Okta -> App |
|
||||
| department | urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department | Okta -> App |
|
||||
|
||||
### Step 4: Implement Error Handling
|
||||
|
||||
SCIM specifies standard error response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "User already exists",
|
||||
"status": "409",
|
||||
"scimType": "uniqueness"
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes: 400 (Bad Request), 401 (Unauthorized), 404 (Not Found), 409 (Conflict), 500 (Internal Server Error).
|
||||
|
||||
### Step 5: Test with Runscope/Okta SCIM Validator
|
||||
|
||||
Okta provides an automated SCIM test suite (via Runscope/BlazeMeter) that validates your SCIM implementation against all required operations:
|
||||
|
||||
1. Import the Okta SCIM 2.0 test suite from the OIN submission portal
|
||||
2. Configure the base URL and authentication token
|
||||
3. Run the full test suite covering user CRUD, filtering, and pagination
|
||||
4. Fix any failing tests before submitting to OIN
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] SCIM server accessible over HTTPS with valid TLS certificate
|
||||
- [ ] Bearer token authentication enforced on all endpoints
|
||||
- [ ] User creation returns 201 with full user representation
|
||||
- [ ] User search by `userName eq "..."` filter works correctly
|
||||
- [ ] Pagination parameters (`startIndex`, `count`) handled properly
|
||||
- [ ] User deactivation sets `active: false` (not hard delete)
|
||||
- [ ] PATCH operations support `add`, `replace`, `remove` ops
|
||||
- [ ] Group push creates and manages group memberships
|
||||
- [ ] Okta SCIM validator test suite passes all tests
|
||||
- [ ] Error responses conform to SCIM error schema
|
||||
|
||||
## References
|
||||
|
||||
- [SCIM 2.0 Protocol RFC 7644](https://tools.ietf.org/html/rfc7644)
|
||||
- [Okta SCIM Developer Guide](https://developer.okta.com/docs/guides/scim-provisioning-integration-overview/main/)
|
||||
- [Build a SCIM API Service - Okta](https://developer.okta.com/docs/guides/scim-provisioning-integration-prepare/main/)
|
||||
- [SCIM Core Schema RFC 7643](https://tools.ietf.org/html/rfc7643)
|
||||
@@ -0,0 +1,75 @@
|
||||
# SCIM Provisioning Implementation Checklist
|
||||
|
||||
## Project: _______________
|
||||
## Date: _______________
|
||||
## Engineer: _______________
|
||||
|
||||
## Pre-Implementation
|
||||
|
||||
- [ ] Okta tenant provisioned with admin access
|
||||
- [ ] Application API supports user CRUD operations
|
||||
- [ ] TLS certificate configured for SCIM endpoint
|
||||
- [ ] Database schema supports SCIM user attributes
|
||||
- [ ] Bearer token generated and securely stored
|
||||
|
||||
## SCIM Server Configuration
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Base URL | `https://______/scim/v2` |
|
||||
| Auth Method | Bearer Token / OAuth 2.0 |
|
||||
| SCIM Version | 2.0 |
|
||||
| Unique ID Field | userName |
|
||||
|
||||
## Attribute Mapping
|
||||
|
||||
| Okta Attribute | SCIM Attribute | Required | Notes |
|
||||
|---------------|----------------|----------|-------|
|
||||
| login | userName | Yes | |
|
||||
| firstName | name.givenName | Yes | |
|
||||
| lastName | name.familyName | Yes | |
|
||||
| email | emails[0].value | Yes | |
|
||||
| department | enterprise:department | No | |
|
||||
| title | title | No | |
|
||||
|
||||
## Endpoint Testing Results
|
||||
|
||||
| Endpoint | Method | Status | Notes |
|
||||
|----------|--------|--------|-------|
|
||||
| /Users | POST | [ ] Pass | Create user |
|
||||
| /Users | GET | [ ] Pass | List/filter users |
|
||||
| /Users/{id} | GET | [ ] Pass | Get single user |
|
||||
| /Users/{id} | PUT | [ ] Pass | Replace user |
|
||||
| /Users/{id} | PATCH | [ ] Pass | Partial update |
|
||||
| /Users/{id} | DELETE | [ ] Pass | Delete user |
|
||||
| /Groups | POST | [ ] Pass | Create group |
|
||||
| /Groups | GET | [ ] Pass | List groups |
|
||||
| /Groups/{id} | PATCH | [ ] Pass | Update members |
|
||||
|
||||
## Okta Configuration
|
||||
|
||||
- [ ] SCIM app integration created
|
||||
- [ ] Provisioning tab configured with base URL and token
|
||||
- [ ] "To App" provisioning enabled (Create, Update, Deactivate)
|
||||
- [ ] Attribute mappings verified
|
||||
- [ ] Group Push configured (if needed)
|
||||
- [ ] Test user assigned and provisioned successfully
|
||||
- [ ] Test user unassigned and deprovisioned successfully
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] Okta SCIM validator test suite passed
|
||||
- [ ] Error responses return correct SCIM error format
|
||||
- [ ] Pagination works with startIndex and count parameters
|
||||
- [ ] Filter on userName eq works correctly
|
||||
- [ ] Deactivation sets active=false (soft delete)
|
||||
- [ ] PATCH operations handle add/replace/remove
|
||||
|
||||
## Production Readiness
|
||||
|
||||
- [ ] SCIM endpoint uses production TLS certificate
|
||||
- [ ] Bearer token rotated from development value
|
||||
- [ ] Rate limiting configured on SCIM endpoints
|
||||
- [ ] Monitoring and alerting set up for provisioning failures
|
||||
- [ ] Provisioning error handling and retry logic tested
|
||||
- [ ] Documentation updated with SCIM integration details
|
||||
@@ -0,0 +1,60 @@
|
||||
# SCIM Provisioning Standards Reference
|
||||
|
||||
## Protocol Standards
|
||||
|
||||
### RFC 7644 - SCIM Protocol
|
||||
- Defines the RESTful API for managing identity resources
|
||||
- Specifies HTTP methods, headers, and response formats
|
||||
- Mandates JSON as the data interchange format
|
||||
- Requires TLS for all communications
|
||||
|
||||
### RFC 7643 - SCIM Core Schema
|
||||
- Defines User, Group, and EnterpriseUser schemas
|
||||
- Specifies attribute types: string, boolean, decimal, integer, dateTime, reference, complex, binary
|
||||
- Defines mutability: readOnly, readWrite, immutable, writeOnly
|
||||
- Specifies attribute uniqueness: none, server, global
|
||||
|
||||
### RFC 7642 - SCIM Definitions, Overview, Concepts, and Requirements
|
||||
- Provides context for the SCIM specification
|
||||
- Defines terminology and use cases
|
||||
- Outlines design requirements for cross-domain provisioning
|
||||
|
||||
## Okta SCIM Requirements
|
||||
|
||||
### Mandatory Endpoints
|
||||
| Endpoint | Methods | Purpose |
|
||||
|----------|---------|---------|
|
||||
| /Users | GET, POST | User listing and creation |
|
||||
| /Users/{id} | GET, PUT, PATCH, DELETE | Individual user operations |
|
||||
| /Groups | GET, POST | Group listing and creation |
|
||||
| /Groups/{id} | GET, PATCH, DELETE | Individual group operations |
|
||||
|
||||
### Required Filter Support
|
||||
- `userName eq "value"` - Exact match on userName
|
||||
- `id eq "value"` - Exact match on user ID
|
||||
- `displayName eq "value"` - Exact match for groups
|
||||
|
||||
### Pagination Requirements
|
||||
- Support `startIndex` and `count` query parameters
|
||||
- Return `totalResults` in ListResponse
|
||||
- Default `startIndex` is 1 (1-based indexing)
|
||||
- Maximum `count` should be configurable
|
||||
|
||||
## Compliance Standards
|
||||
|
||||
### SOC 2 Type II
|
||||
- Automated provisioning demonstrates access control effectiveness
|
||||
- Deprovisioning within defined SLA shows timely access removal
|
||||
- Audit logs of SCIM operations provide evidence for access reviews
|
||||
|
||||
### ISO 27001 - A.9.2 User Access Management
|
||||
- A.9.2.1: User registration and deregistration (automated via SCIM)
|
||||
- A.9.2.2: User access provisioning (role-based assignment)
|
||||
- A.9.2.5: Review of user access rights (SCIM audit logs)
|
||||
- A.9.2.6: Removal of access rights (automated deprovisioning)
|
||||
|
||||
### NIST SP 800-53 - AC (Access Control)
|
||||
- AC-2: Account Management (automated lifecycle)
|
||||
- AC-2(1): Automated System Account Management
|
||||
- AC-2(4): Automated Audit Actions
|
||||
- AC-6: Least Privilege (role-based provisioning)
|
||||
@@ -0,0 +1,96 @@
|
||||
# SCIM Provisioning Workflows
|
||||
|
||||
## User Provisioning Workflow
|
||||
|
||||
```
|
||||
1. Admin assigns user to Okta application
|
||||
│
|
||||
2. Okta checks if user exists (GET /Users?filter=userName eq "user@domain.com")
|
||||
│
|
||||
├── User NOT found → Okta sends POST /Users with user attributes
|
||||
│ │
|
||||
│ └── SCIM server creates user → Returns 201 Created
|
||||
│
|
||||
└── User found → Okta sends PUT /Users/{id} to update attributes
|
||||
│
|
||||
└── SCIM server updates user → Returns 200 OK
|
||||
```
|
||||
|
||||
## User Deprovisioning Workflow
|
||||
|
||||
```
|
||||
1. Admin unassigns user from Okta application (or user deactivated in Okta)
|
||||
│
|
||||
2. Okta sends PATCH /Users/{id}
|
||||
Body: {"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations":[{"op":"replace","value":{"active":false}}]}
|
||||
│
|
||||
3. SCIM server deactivates user (sets active=false, revokes sessions)
|
||||
│
|
||||
4. Returns 200 OK with updated user object
|
||||
```
|
||||
|
||||
## Group Push Workflow
|
||||
|
||||
```
|
||||
1. Admin enables Group Push for Okta group
|
||||
│
|
||||
2. Okta sends POST /Groups with group name and initial members
|
||||
│
|
||||
3. When group membership changes in Okta:
|
||||
│
|
||||
├── Member added → PATCH /Groups/{id}
|
||||
│ Op: add, path: members, value: [{value: userId}]
|
||||
│
|
||||
└── Member removed → PATCH /Groups/{id}
|
||||
Op: remove, path: members[value eq "userId"]
|
||||
```
|
||||
|
||||
## Profile Sync Workflow
|
||||
|
||||
```
|
||||
1. User profile updated in Okta (e.g., department change)
|
||||
│
|
||||
2. Okta sends PUT /Users/{id} or PATCH /Users/{id}
|
||||
Body includes updated attributes
|
||||
│
|
||||
3. SCIM server updates user attributes in local database
|
||||
│
|
||||
4. Returns 200 OK with full updated user representation
|
||||
```
|
||||
|
||||
## Error Recovery Workflow
|
||||
|
||||
```
|
||||
1. SCIM operation fails (network timeout, server error)
|
||||
│
|
||||
2. Okta logs failed task in Provisioning > Tasks
|
||||
│
|
||||
3. Admin can retry individual failed tasks
|
||||
│
|
||||
4. For persistent failures:
|
||||
├── Check SCIM server logs for error details
|
||||
├── Verify network connectivity and TLS certificate
|
||||
├── Validate bearer token has not expired
|
||||
└── Review attribute mapping for data format issues
|
||||
```
|
||||
|
||||
## Implementation Testing Workflow
|
||||
|
||||
```
|
||||
1. Deploy SCIM server to staging environment
|
||||
│
|
||||
2. Configure Okta SCIM integration with staging URL
|
||||
│
|
||||
3. Run Okta SCIM validator test suite
|
||||
│
|
||||
4. Test manual operations:
|
||||
├── Assign test user → verify account created
|
||||
├── Update user profile → verify attributes synced
|
||||
├── Unassign user → verify account deactivated
|
||||
└── Push group → verify group and members created
|
||||
│
|
||||
5. Review provisioning logs in Okta Admin Console
|
||||
│
|
||||
6. Promote to production with production SCIM URL
|
||||
```
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SCIM 2.0 Provisioning Server for Okta Integration
|
||||
|
||||
A production-ready SCIM 2.0 server implementation that handles user and group
|
||||
lifecycle management from Okta. Supports user CRUD, group push, filtering,
|
||||
pagination, and PATCH operations per RFC 7644.
|
||||
|
||||
Requirements:
|
||||
pip install flask sqlalchemy
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, request, jsonify, g
|
||||
from sqlalchemy import create_engine, Column, String, Boolean, DateTime, Table, ForeignKey
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DATABASE_URL = "sqlite:///scim_users.db"
|
||||
SCIM_BEARER_TOKEN = "replace-with-secure-token"
|
||||
SCIM_BASE_URL = "https://your-app.example.com/scim/v2"
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
# Association table for user-group membership
|
||||
user_group = Table(
|
||||
"user_group", Base.metadata,
|
||||
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
|
||||
Column("group_id", String, ForeignKey("groups.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
userName = Column(String, unique=True, nullable=False)
|
||||
givenName = Column(String, default="")
|
||||
familyName = Column(String, default="")
|
||||
email = Column(String, default="")
|
||||
displayName = Column(String, default="")
|
||||
active = Column(Boolean, default=True)
|
||||
department = Column(String, default="")
|
||||
title = Column(String, default="")
|
||||
created = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
lastModified = Column(DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
groups = relationship("Group", secondary=user_group, back_populates="members")
|
||||
|
||||
def to_scim(self):
|
||||
return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],
|
||||
"id": self.id,
|
||||
"userName": self.userName,
|
||||
"name": {
|
||||
"givenName": self.givenName or "",
|
||||
"familyName": self.familyName or "",
|
||||
"formatted": f"{self.givenName or ''} {self.familyName or ''}".strip()
|
||||
},
|
||||
"displayName": self.displayName or f"{self.givenName or ''} {self.familyName or ''}".strip(),
|
||||
"emails": [{"value": self.email, "type": "work", "primary": True}] if self.email else [],
|
||||
"active": self.active,
|
||||
"title": self.title or "",
|
||||
"groups": [{"value": g.id, "display": g.displayName} for g in self.groups],
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
|
||||
"department": self.department or ""
|
||||
},
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": self.created.isoformat() + "Z" if self.created else "",
|
||||
"lastModified": self.lastModified.isoformat() + "Z" if self.lastModified else "",
|
||||
"location": f"{SCIM_BASE_URL}/Users/{self.id}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
displayName = Column(String, unique=True, nullable=False)
|
||||
created = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
lastModified = Column(DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
members = relationship("User", secondary=user_group, back_populates="groups")
|
||||
|
||||
def to_scim(self):
|
||||
return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"id": self.id,
|
||||
"displayName": self.displayName,
|
||||
"members": [{"value": m.id, "display": m.displayName or m.userName} for m in self.members],
|
||||
"meta": {
|
||||
"resourceType": "Group",
|
||||
"created": self.created.isoformat() + "Z" if self.created else "",
|
||||
"lastModified": self.lastModified.isoformat() + "Z" if self.lastModified else "",
|
||||
"location": f"{SCIM_BASE_URL}/Groups/{self.id}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
if "db" not in g:
|
||||
g.db = SessionLocal()
|
||||
return g.db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exception):
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def scim_error(detail, status, scim_type=None):
|
||||
body = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": detail,
|
||||
"status": str(status)
|
||||
}
|
||||
if scim_type:
|
||||
body["scimType"] = scim_type
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer ") or auth[7:] != SCIM_BEARER_TOKEN:
|
||||
return scim_error("Authentication required", 401)
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def parse_scim_filter(filter_str):
|
||||
"""Parse simple SCIM filter expressions like: userName eq 'value'"""
|
||||
match = re.match(r'(\w+)\s+eq\s+"([^"]*)"', filter_str)
|
||||
if not match:
|
||||
match = re.match(r"(\w+)\s+eq\s+'([^']*)'", filter_str)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return None, None
|
||||
|
||||
|
||||
def list_response(resources, total, start_index, count):
|
||||
return jsonify({
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": total,
|
||||
"startIndex": start_index,
|
||||
"itemsPerPage": count,
|
||||
"Resources": resources
|
||||
})
|
||||
|
||||
|
||||
# ---- User Endpoints ----
|
||||
|
||||
@app.route("/scim/v2/Users", methods=["POST"])
|
||||
@require_auth
|
||||
def create_user():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
|
||||
username = data.get("userName")
|
||||
if not username:
|
||||
return scim_error("userName is required", 400)
|
||||
|
||||
existing = db.query(User).filter(User.userName == username).first()
|
||||
if existing:
|
||||
return scim_error(f"User with userName '{username}' already exists", 409, "uniqueness")
|
||||
|
||||
name = data.get("name", {})
|
||||
emails = data.get("emails", [])
|
||||
enterprise = data.get("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", {})
|
||||
|
||||
user = User(
|
||||
userName=username,
|
||||
givenName=name.get("givenName", ""),
|
||||
familyName=name.get("familyName", ""),
|
||||
displayName=data.get("displayName", ""),
|
||||
email=emails[0].get("value", "") if emails else "",
|
||||
active=data.get("active", True),
|
||||
department=enterprise.get("department", ""),
|
||||
title=data.get("title", ""),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return jsonify(user.to_scim()), 201
|
||||
|
||||
|
||||
@app.route("/scim/v2/Users", methods=["GET"])
|
||||
@require_auth
|
||||
def list_users():
|
||||
db = get_db()
|
||||
start_index = int(request.args.get("startIndex", 1))
|
||||
count = int(request.args.get("count", 100))
|
||||
filter_str = request.args.get("filter", "")
|
||||
|
||||
query = db.query(User)
|
||||
|
||||
if filter_str:
|
||||
attr, value = parse_scim_filter(filter_str)
|
||||
if attr == "userName":
|
||||
query = query.filter(User.userName == value)
|
||||
elif attr == "id":
|
||||
query = query.filter(User.id == value)
|
||||
|
||||
total = query.count()
|
||||
users = query.offset(start_index - 1).limit(count).all()
|
||||
|
||||
return list_response([u.to_scim() for u in users], total, start_index, count)
|
||||
|
||||
|
||||
@app.route("/scim/v2/Users/<user_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_user(user_id):
|
||||
db = get_db()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return scim_error("User not found", 404)
|
||||
return jsonify(user.to_scim())
|
||||
|
||||
|
||||
@app.route("/scim/v2/Users/<user_id>", methods=["PUT"])
|
||||
@require_auth
|
||||
def replace_user(user_id):
|
||||
db = get_db()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return scim_error("User not found", 404)
|
||||
|
||||
data = request.json
|
||||
name = data.get("name", {})
|
||||
emails = data.get("emails", [])
|
||||
enterprise = data.get("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", {})
|
||||
|
||||
user.userName = data.get("userName", user.userName)
|
||||
user.givenName = name.get("givenName", user.givenName)
|
||||
user.familyName = name.get("familyName", user.familyName)
|
||||
user.displayName = data.get("displayName", user.displayName)
|
||||
user.email = emails[0].get("value", user.email) if emails else user.email
|
||||
user.active = data.get("active", user.active)
|
||||
user.department = enterprise.get("department", user.department)
|
||||
user.title = data.get("title", user.title)
|
||||
user.lastModified = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return jsonify(user.to_scim())
|
||||
|
||||
|
||||
@app.route("/scim/v2/Users/<user_id>", methods=["PATCH"])
|
||||
@require_auth
|
||||
def patch_user(user_id):
|
||||
db = get_db()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return scim_error("User not found", 404)
|
||||
|
||||
data = request.json
|
||||
operations = data.get("Operations", [])
|
||||
|
||||
for op in operations:
|
||||
operation = op.get("op", "").lower()
|
||||
path = op.get("path", "")
|
||||
value = op.get("value", {})
|
||||
|
||||
if operation == "replace":
|
||||
if isinstance(value, dict):
|
||||
if "active" in value:
|
||||
user.active = value["active"]
|
||||
if "userName" in value:
|
||||
user.userName = value["userName"]
|
||||
if "name" in value:
|
||||
user.givenName = value["name"].get("givenName", user.givenName)
|
||||
user.familyName = value["name"].get("familyName", user.familyName)
|
||||
elif path == "active":
|
||||
user.active = value
|
||||
elif path == "userName":
|
||||
user.userName = value
|
||||
|
||||
user.lastModified = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return jsonify(user.to_scim())
|
||||
|
||||
|
||||
@app.route("/scim/v2/Users/<user_id>", methods=["DELETE"])
|
||||
@require_auth
|
||||
def delete_user(user_id):
|
||||
db = get_db()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return scim_error("User not found", 404)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
# ---- Group Endpoints ----
|
||||
|
||||
@app.route("/scim/v2/Groups", methods=["POST"])
|
||||
@require_auth
|
||||
def create_group():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
|
||||
display_name = data.get("displayName")
|
||||
if not display_name:
|
||||
return scim_error("displayName is required", 400)
|
||||
|
||||
existing = db.query(Group).filter(Group.displayName == display_name).first()
|
||||
if existing:
|
||||
return scim_error(f"Group '{display_name}' already exists", 409, "uniqueness")
|
||||
|
||||
group = Group(displayName=display_name)
|
||||
|
||||
for member_data in data.get("members", []):
|
||||
user = db.query(User).filter(User.id == member_data.get("value")).first()
|
||||
if user:
|
||||
group.members.append(user)
|
||||
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return jsonify(group.to_scim()), 201
|
||||
|
||||
|
||||
@app.route("/scim/v2/Groups", methods=["GET"])
|
||||
@require_auth
|
||||
def list_groups():
|
||||
db = get_db()
|
||||
start_index = int(request.args.get("startIndex", 1))
|
||||
count = int(request.args.get("count", 100))
|
||||
filter_str = request.args.get("filter", "")
|
||||
|
||||
query = db.query(Group)
|
||||
|
||||
if filter_str:
|
||||
attr, value = parse_scim_filter(filter_str)
|
||||
if attr == "displayName":
|
||||
query = query.filter(Group.displayName == value)
|
||||
|
||||
total = query.count()
|
||||
groups = query.offset(start_index - 1).limit(count).all()
|
||||
|
||||
return list_response([g.to_scim() for g in groups], total, start_index, count)
|
||||
|
||||
|
||||
@app.route("/scim/v2/Groups/<group_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_group(group_id):
|
||||
db = get_db()
|
||||
group = db.query(Group).filter(Group.id == group_id).first()
|
||||
if not group:
|
||||
return scim_error("Group not found", 404)
|
||||
return jsonify(group.to_scim())
|
||||
|
||||
|
||||
@app.route("/scim/v2/Groups/<group_id>", methods=["PATCH"])
|
||||
@require_auth
|
||||
def patch_group(group_id):
|
||||
db = get_db()
|
||||
group = db.query(Group).filter(Group.id == group_id).first()
|
||||
if not group:
|
||||
return scim_error("Group not found", 404)
|
||||
|
||||
data = request.json
|
||||
for op in data.get("Operations", []):
|
||||
operation = op.get("op", "").lower()
|
||||
path = op.get("path", "")
|
||||
value = op.get("value", [])
|
||||
|
||||
if operation == "add" and "members" in path:
|
||||
members_to_add = value if isinstance(value, list) else [value]
|
||||
for member_data in members_to_add:
|
||||
user = db.query(User).filter(User.id == member_data.get("value")).first()
|
||||
if user and user not in group.members:
|
||||
group.members.append(user)
|
||||
|
||||
elif operation == "remove" and "members" in path:
|
||||
member_filter = re.search(r'members\[value eq "([^"]+)"\]', path)
|
||||
if member_filter:
|
||||
uid = member_filter.group(1)
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if user and user in group.members:
|
||||
group.members.remove(user)
|
||||
|
||||
elif operation == "replace" and path == "displayName":
|
||||
group.displayName = value
|
||||
|
||||
group.lastModified = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return jsonify(group.to_scim())
|
||||
|
||||
|
||||
@app.route("/scim/v2/Groups/<group_id>", methods=["DELETE"])
|
||||
@require_auth
|
||||
def delete_group(group_id):
|
||||
db = get_db()
|
||||
group = db.query(Group).filter(Group.id == group_id).first()
|
||||
if not group:
|
||||
return scim_error("Group not found", 404)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
# ---- Service Provider Config ----
|
||||
|
||||
@app.route("/scim/v2/ServiceProviderConfig", methods=["GET"])
|
||||
def service_provider_config():
|
||||
return jsonify({
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
"documentationUri": "https://developer.okta.com/docs/concepts/scim/",
|
||||
"patch": {"supported": True},
|
||||
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||
"filter": {"supported": True, "maxResults": 200},
|
||||
"changePassword": {"supported": False},
|
||||
"sort": {"supported": False},
|
||||
"etag": {"supported": False},
|
||||
"authenticationSchemes": [{
|
||||
"type": "oauthbearertoken",
|
||||
"name": "OAuth Bearer Token",
|
||||
"description": "Authentication scheme using the OAuth Bearer Token Standard",
|
||||
"specUri": "https://www.rfc-editor.org/info/rfc6750"
|
||||
}]
|
||||
})
|
||||
|
||||
|
||||
@app.route("/scim/v2/ResourceTypes", methods=["GET"])
|
||||
def resource_types():
|
||||
return jsonify({
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": 2,
|
||||
"Resources": [
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
||||
"id": "User",
|
||||
"name": "User",
|
||||
"endpoint": "/Users",
|
||||
"schema": "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
},
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
||||
"id": "Group",
|
||||
"name": "Group",
|
||||
"endpoint": "/Groups",
|
||||
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8080, debug=True)
|
||||
Reference in New Issue
Block a user