Skip to content
Back to skills

Rfc 7644 Scim Protocol

ASecurity

SCIM REST API protocol for identity provisioning operations. Use when implementing or validating rfc 7644 scim protocol protocol compliance.

  • 8 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 8, 2026
developmentpythongoexpressfastapigitapidocumentation

Works with

  • api

Security analysis

A100/100

Scanned September 8, 2026

npx -y skills add anubhavg-icpl/vibe --skill rfc-7644-scim-protocol --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Rfc 7644 Scim Protocol?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Rfc 7644 Scim Protocol
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/anubhavg-icpl-rfc-7644-scim-protocol/badge)](https://www.skillsdirectory.com/skills/anubhavg-icpl-rfc-7644-scim-protocol)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: rfc-7644-scim-protocol
description: SCIM REST API protocol for identity provisioning operations. Use when implementing or validating rfc 7644 scim protocol protocol compliance.
license: CC-BY-NC-SA-4.0
metadata:
  risk: unknown
  source: community
  kind: mode
  category: rfc
  tags: [scim, rest, api, provisioning, multi-tenancy]
---

# RFC 7644 - SCIM Protocol

You are an expert in implementing SCIM Protocol as defined in RFC 7644. You create REST APIs for identity provisioning with filtering, pagination, and bulk operations.

## RFC Overview

| Property   | Value                                                 |
| ---------- | ----------------------------------------------------- |
| RFC Number | 7644                                                  |
| Title      | System for Cross-domain Identity Management: Protocol |
| Status     | Proposed Standard                                     |
| Published  | September 2015                                        |
| Requires   | RFC 7643 (Core Schema)                                |

## SCIM Endpoints

```text
┌─────────────────────────────────────────────────────────────────┐
│                      SCIM REST API                               │
│                                                                  │
│  Base URL: https://api.example.com/scim/v2                      │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ Resource Endpoints                                         │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ GET    /Users                    List/Search users        │  │
│  │ POST   /Users                    Create user              │  │
│  │ GET    /Users/{id}               Get user                 │  │
│  │ PUT    /Users/{id}               Replace user             │  │
│  │ PATCH  /Users/{id}               Modify user              │  │
│  │ DELETE /Users/{id}               Delete user              │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ GET    /Groups                   List/Search groups       │  │
│  │ POST   /Groups                   Create group             │  │
│  │ GET    /Groups/{id}              Get group                │  │
│  │ PUT    /Groups/{id}              Replace group            │  │
│  │ PATCH  /Groups/{id}              Modify group             │  │
│  │ DELETE /Groups/{id}              Delete group             │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ Discovery & Operations                                     │  │
│  │ GET    /ServiceProviderConfig    Service capabilities     │  │
│  │ GET    /Schemas                  Schema definitions       │  │
│  │ GET    /ResourceTypes            Resource type info       │  │
│  │ POST   /Bulk                     Bulk operations          │  │
│  │ POST   /.search                  Cross-resource search    │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
```

## Implementation

### FastAPI SCIM Server

```python
from fastapi import FastAPI, Request, Response, HTTPException, Query, Depends
from fastapi.responses import JSONResponse
from typing import Optional, List
from pydantic import BaseModel
import re

app = FastAPI(title="SCIM 2.0 API")

SCIM_CONTENT_TYPE = "application/scim+json"

# Request/Response Models
class SCIMListResponse(BaseModel):
    """SCIM List Response (RFC 7644 Section 3.4.2)"""
    schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
    totalResults: int
    startIndex: int = 1
    itemsPerPage: int
    Resources: List[dict]


class SCIMError(BaseModel):
    """SCIM Error Response (RFC 7644 Section 3.12)"""
    schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:Error"]
    status: str
    scimType: Optional[str] = None
    detail: Optional[str] = None


class SCIMPatchOp(BaseModel):
    """SCIM Patch Operation (RFC 7644 Section 3.5.2)"""
    op: str  # "add", "remove", "replace"
    path: Optional[str] = None
    value: Optional[any] = None


class SCIMPatchRequest(BaseModel):
    """SCIM Patch Request"""
    schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
    Operations: List[SCIMPatchOp]


# Dependency for tenant extraction
def get_tenant(request: Request) -> str:
    """Extract tenant from request."""
    # From header
    tenant = request.headers.get("X-Tenant-ID")
    if tenant:
        return tenant

    # From subdomain
    host = request.headers.get("host", "")
    if "." in host:
        return host.split(".")[0]

    raise HTTPException(400, detail="Tenant not specified")


# Initialize store
store = MultiTenantSCIMStore()


@app.post("/scim/v2/Users", response_class=JSONResponse)
async def create_user(
    request: Request,
    tenant_id: str = Depends(get_tenant),
):
    """
    Create User (RFC 7644 Section 3.3)

    Returns: 201 Created with Location header
    """
    data = await request.json()

    # Validate
    errors = SCIMValidator.validate_user(data)
    if errors:
        return scim_error_response(400, "invalidValue", "; ".join(errors))

    try:
        user = SCIMUser.from_dict(data)
        created = store.create_user(tenant_id, user)

        response = JSONResponse(
            content=created.to_dict(),
            status_code=201,
            media_type=SCIM_CONTENT_TYPE,
        )
        response.headers["Location"] = f"/scim/v2/Users/{created.id}"
        response.headers["ETag"] = created.meta.version

        return response

    except SCIMConflictError as e:
        return scim_error_response(409, "uniqueness", str(e))


@app.get("/scim/v2/Users/{user_id}", response_class=JSONResponse)
async def get_user(
    user_id: str,
    tenant_id: str = Depends(get_tenant),
):
    """
    Get User (RFC 7644 Section 3.4.1)
    """
    user = store.get_user(tenant_id, user_id)

    if not user:
        return scim_error_response(404, None, f"User {user_id} not found")

    response = JSONResponse(
        content=user.to_dict(),
        media_type=SCIM_CONTENT_TYPE,
    )
    response.headers["ETag"] = user.meta.version

    return response


@app.get("/scim/v2/Users", response_class=JSONResponse)
async def list_users(
    tenant_id: str = Depends(get_tenant),
    filter: Optional[str] = Query(None, description="SCIM filter expression"),
    sortBy: Optional[str] = Query(None),
    sortOrder: Optional[str] = Query("ascending"),
    startIndex: int = Query(1, ge=1),
    count: int = Query(100, ge=0, le=1000),
    attributes: Optional[str] = Query(None, description="Comma-separated attributes"),
    excludedAttributes: Optional[str] = Query(None),
):
    """
    List/Search Users (RFC 7644 Section 3.4.2)
    """
    users, total = store.list_users(
        tenant_id,
        filter_expr=filter,
        start_index=startIndex,
        count=count,
    )

    # Apply attribute filtering
    resources = [u.to_dict() for u in users]
    if attributes:
        attrs = set(attributes.split(","))
        resources = [filter_attributes(r, attrs) for r in resources]

    response = SCIMListResponse(
        totalResults=total,
        startIndex=startIndex,
        itemsPerPage=len(resources),
        Resources=resources,
    )

    return JSONResponse(
        content=response.dict(),
        media_type=SCIM_CONTENT_TYPE,
    )


@app.put("/scim/v2/Users/{user_id}", response_class=JSONResponse)
async def replace_user(
    user_id: str,
    request: Request,
    tenant_id: str = Depends(get_tenant),
):
    """
    Replace User (RFC 7644 Section 3.5.1)
    """
    data = await request.json()

    # Validate If-Match for optimistic locking
    if_match = request.headers.get("If-Match")

    existing = store.get_user(tenant_id, user_id)
    if not existing:
        return scim_error_response(404, None, f"User {user_id} not found")

    if if_match and if_match != existing.meta.version:
        return scim_error_response(412, None, "Version mismatch")

    try:
        user = SCIMUser.from_dict(data)
        user.id = user_id  # Preserve ID
        updated = store.update_user(tenant_id, user)

        response = JSONResponse(
            content=updated.to_dict(),
            media_type=SCIM_CONTENT_TYPE,
        )
        response.headers["ETag"] = updated.meta.version

        return response

    except SCIMConflictError as e:
        return scim_error_response(409, "uniqueness", str(e))


@app.patch("/scim/v2/Users/{user_id}", response_class=JSONResponse)
async def patch_user(
    user_id: str,
    request: Request,
    tenant_id: str = Depends(get_tenant),
):
    """
    Modify User (RFC 7644 Section 3.5.2)
    """
    data = await request.json()
    patch_request = SCIMPatchRequest(**data)

    existing = store.get_user(tenant_id, user_id)
    if not existing:
        return scim_error_response(404, None, f"User {user_id} not found")

    # Apply patch operations
    user_dict = existing.to_dict()

    for op in patch_request.Operations:
        user_dict = apply_patch_operation(user_dict, op)

    updated_user = SCIMUser.from_dict(user_dict)
    updated_user.id = user_id

    result = store.update_user(tenant_id, updated_user)

    return JSONResponse(
        content=result.to_dict(),
        media_type=SCIM_CONTENT_TYPE,
    )


@app.delete("/scim/v2/Users/{user_id}")
async def delete_user(
    user_id: str,
    tenant_id: str = Depends(get_tenant),
):
    """
    Delete User (RFC 7644 Section 3.6)

    Returns: 204 No Content
    """
    existing = store.get_user(tenant_id, user_id)
    if not existing:
        return scim_error_response(404, None, f"User {user_id} not found")

    store.delete_user(tenant_id, user_id)

    return Response(status_code=204)


def apply_patch_operation(resource: dict, op: SCIMPatchOp) -> dict:
    """
    Apply SCIM PATCH operation (RFC 7644 Section 3.5.2).
    """
    if op.op.lower() == "add":
        if op.path:
            # Path-based add
            set_path_value(resource, op.path, op.value, add=True)
        else:
            # Value is object to merge
            resource.update(op.value)

    elif op.op.lower() == "remove":
        if not op.path:
            raise ValueError("path required for remove")
        remove_path_value(resource, op.path)

    elif op.op.lower() == "replace":
        if op.path:
            set_path_value(resource, op.path, op.value)
        else:
            resource.update(op.value)

    return resource


def set_path_value(resource: dict, path: str, value: any, add: bool = False):
    """Set value at SCIM path."""
    # Parse path (simplified - production needs full SCIM path parser)
    # Handles: "displayName", "name.familyName", "emails[type eq \"work\"].value"

    if "." in path and "[" not in path:
        # Simple nested path
        parts = path.split(".")
        obj = resource
        for part in parts[:-1]:
            if part not in obj:
                obj[part] = {}
            obj = obj[part]
        obj[parts[-1]] = value

    elif "[" in path:
        # Filter path like emails[type eq "work"].value
        match = re.match(r'(\w+)\[(.+)\]\.?(\w+)?', path)
        if match:
            attr, filter_expr, sub_attr = match.groups()
            array = resource.get(attr, [])

            # Find matching element
            for item in array:
                if evaluate_filter(item, filter_expr):
                    if sub_attr:
                        item[sub_attr] = value
                    break
            else:
                # No match - add new element if 'add' operation
                if add:
                    new_item = {"value": value} if sub_attr == "value" else {sub_attr: value}
                    array.append(new_item)
                    resource[attr] = array
    else:
        resource[path] = value


def scim_error_response(status: int, scim_type: Optional[str], detail: str) -> JSONResponse:
    """Create SCIM error response."""
    error = SCIMError(
        status=str(status),
        scimType=scim_type,
        detail=detail,
    )
    return JSONResponse(
        content=error.dict(exclude_none=True),
        status_code=status,
        media_type=SCIM_CONTENT_TYPE,
    )
```

### Filtering (RFC 7644 Section 3.4.2.2)

```python
import re
from typing import Any, Callable
from dataclasses import dataclass
from enum import Enum

class FilterOperator(Enum):
    EQ = "eq"      # equals
    NE = "ne"      # not equals
    CO = "co"      # contains
    SW = "sw"      # starts with
    EW = "ew"      # ends with
    GT = "gt"      # greater than
    GE = "ge"      # greater than or equal
    LT = "lt"      # less than
    LE = "le"      # less than or equal
    PR = "pr"      # present (has value)


@dataclass
class FilterExpression:
    attribute: str
    operator: FilterOperator
    value: Any = None


class SCIMFilterParser:
    """Parse SCIM filter expressions (RFC 7644 Section 3.4.2.2)."""

    OPERATORS = {
        "eq": FilterOperator.EQ,
        "ne": FilterOperator.NE,
        "co": FilterOperator.CO,
        "sw": FilterOperator.SW,
        "ew": FilterOperator.EW,
        "gt": FilterOperator.GT,
        "ge": FilterOperator.GE,
        "lt": FilterOperator.LT,
        "le": FilterOperator.LE,
        "pr": FilterOperator.PR,
    }

    @classmethod
    def parse(cls, filter_string: str) -> Callable[[dict], bool]:
        """
        Parse filter string and return predicate function.

        Examples:
            userName eq "john"
            emails.value co "@example.com"
            name.familyName sw "J" and active eq true
            not (userName eq "admin")
        """
        # Handle logical operators
        if " and " in filter_string.lower():
            parts = re.split(r'\s+and\s+', filter_string, flags=re.IGNORECASE)
            predicates = [cls.parse(p.strip()) for p in parts]
            return lambda r: all(p(r) for p in predicates)

        if " or " in filter_string.lower():
            parts = re.split(r'\s+or\s+', filter_string, flags=re.IGNORECASE)
            predicates = [cls.parse(p.strip()) for p in parts]
            return lambda r: any(p(r) for p in predicates)

        if filter_string.lower().startswith("not "):
            inner = cls.parse(filter_string[4:].strip().strip("()"))
            return lambda r: not inner(r)

        # Parse single expression
        expr = cls._parse_expression(filter_string)
        return cls._create_predicate(expr)

    @classmethod
    def _parse_expression(cls, expr_string: str) -> FilterExpression:
        """Parse single filter expression."""
        # Handle "pr" (present) operator
        if expr_string.strip().endswith(" pr"):
            attr = expr_string.strip()[:-3].strip()
            return FilterExpression(attr, FilterOperator.PR)

        # Parse comparison: attr op value
        pattern = r'([\w.]+)\s+(eq|ne|co|sw|ew|gt|ge|lt|le)\s+(.+)'
        match = re.match(pattern, expr_string.strip(), re.IGNORECASE)

        if not match:
            raise ValueError(f"Invalid filter expression: {expr_string}")

        attr, op, value = match.groups()

        # Parse value
        value = value.strip()
        if value.startswith('"') and value.endswith('"'):
            value = value[1:-1]
        elif value.lower() == "true":
            value = True
        elif value.lower() == "false":
            value = False
        elif value.isdigit():
            value = int(value)

        return FilterExpression(
            attribute=attr,
            operator=cls.OPERATORS[op.lower()],
            value=value,
        )

    @classmethod
    def _create_predicate(cls, expr: FilterExpression) -> Callable[[dict], bool]:
        """Create predicate function from expression."""

        def get_value(resource: dict, path: str) -> Any:
            """Get value at path, handling nested and multi-valued."""
            parts = path.split(".")

            current = resource
            for part in parts:
                if isinstance(current, dict):
                    current = current.get(part)
                elif isinstance(current, list):
                    # Multi-valued: return list of sub-values
                    return [item.get(part) for item in current if isinstance(item, dict)]
                else:
                    return None

            return current

        def predicate(resource: dict) -> bool:
            value = get_value(resource, expr.attribute)

            # Handle multi-valued attributes
            if isinstance(value, list):
                return any(cls._compare(v, expr.operator, expr.value) for v in value)

            return cls._compare(value, expr.operator, expr.value)

        return predicate

    @staticmethod
    def _compare(value: Any, operator: FilterOperator, compare_to: Any) -> bool:
        """Perform comparison operation."""
        if operator == FilterOperator.PR:
            return value is not None

        if value is None:
            return False

        # Case-insensitive string comparison
        if isinstance(value, str) and isinstance(compare_to, str):
            value = value.lower()
            compare_to = compare_to.lower()

        comparisons = {
            FilterOperator.EQ: lambda: value == compare_to,
            FilterOperator.NE: lambda: value != compare_to,
            FilterOperator.CO: lambda: compare_to in str(value),
            FilterOperator.SW: lambda: str(value).startswith(str(compare_to)),
            FilterOperator.EW: lambda: str(value).endswith(str(compare_to)),
            FilterOperator.GT: lambda: value > compare_to,
            FilterOperator.GE: lambda: value >= compare_to,
            FilterOperator.LT: lambda: value < compare_to,
            FilterOperator.LE: lambda: value <= compare_to,
        }

        return comparisons.get(operator, lambda: False)()
```

### Bulk Operations (RFC 7644 Section 3.7)

```python
@dataclass
class BulkOperation:
    method: str  # POST, PUT, PATCH, DELETE
    path: str
    bulkId: Optional[str] = None
    data: Optional[dict] = None


@dataclass
class BulkResponse:
    method: str
    bulkId: Optional[str]
    location: Optional[str]
    status: str
    response: Optional[dict] = None


@app.post("/scim/v2/Bulk")
async def bulk_operations(
    request: Request,
    tenant_id: str = Depends(get_tenant),
):
    """
    Bulk Operations (RFC 7644 Section 3.7)
    """
    data = await request.json()

    operations = data.get("Operations", [])
    fail_on_errors = data.get("failOnErrors", len(operations))

    results = []
    error_count = 0
    bulkid_to_id = {}  # Map bulkId to created resource ID

    for op in operations:
        if error_count >= fail_on_errors:
            break

        try:
            result = await process_bulk_operation(
                tenant_id,
                BulkOperation(
                    method=op["method"],
                    path=op["path"],
                    bulkId=op.get("bulkId"),
                    data=op.get("data"),
                ),
                bulkid_to_id,
            )
            results.append(result)

            # Track bulkId mapping for cross-references
            if result.bulkId and result.location:
                resource_id = result.location.split("/")[-1]
                bulkid_to_id[f"bulkId:{result.bulkId}"] = resource_id

        except Exception as e:
            error_count += 1
            results.append(BulkResponse(
                method=op["method"],
                bulkId=op.get("bulkId"),
                location=None,
                status="400",
                response={"error": str(e)},
            ))

    return JSONResponse(
        content={
            "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"],
            "Operations": [r.__dict__ for r in results],
        },
        media_type=SCIM_CONTENT_TYPE,
    )


async def process_bulk_operation(
    tenant_id: str,
    op: BulkOperation,
    bulkid_map: dict,
) -> BulkResponse:
    """Process single bulk operation."""

    # Resolve bulkId references in data
    if op.data:
        op.data = resolve_bulk_references(op.data, bulkid_map)

    if op.method == "POST":
        if op.path == "/Users":
            user = SCIMUser.from_dict(op.data)
            created = store.create_user(tenant_id, user)
            return BulkResponse(
                method="POST",
                bulkId=op.bulkId,
                location=f"/scim/v2/Users/{created.id}",
                status="201",
            )

    elif op.method == "DELETE":
        # Extract ID from path
        match = re.match(r'/(\w+)/(.+)', op.path)
        if match:
            resource_type, resource_id = match.groups()
            if resource_type == "Users":
                store.delete_user(tenant_id, resource_id)
                return BulkResponse(
                    method="DELETE",
                    bulkId=op.bulkId,
                    location=None,
                    status="204",
                )

    raise ValueError(f"Unsupported operation: {op.method} {op.path}")


def resolve_bulk_references(data: dict, bulkid_map: dict) -> dict:
    """Resolve bulkId:xxx references to actual IDs."""
    import json

    json_str = json.dumps(data)
    for ref, actual_id in bulkid_map.items():
        json_str = json_str.replace(ref, actual_id)

    return json.loads(json_str)
```

### Service Provider Configuration

```python
@app.get("/scim/v2/ServiceProviderConfig")
async def service_provider_config():
    """
    Service Provider Configuration (RFC 7644 Section 4)
    """
    return JSONResponse(
        content={
            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
            "documentationUri": "https://docs.example.com/scim",
            "patch": {"supported": True},
            "bulk": {
                "supported": True,
                "maxOperations": 1000,
                "maxPayloadSize": 1048576,
            },
            "filter": {
                "supported": True,
                "maxResults": 1000,
            },
            "changePassword": {"supported": False},
            "sort": {"supported": True},
            "etag": {"supported": True},
            "authenticationSchemes": [
                {
                    "type": "oauthbearertoken",
                    "name": "OAuth 2.0 Bearer Token",
                    "description": "Authentication using OAuth 2.0 Bearer Tokens",
                    "specUri": "https://tools.ietf.org/html/rfc6750",
                }
            ],
        },
        media_type=SCIM_CONTENT_TYPE,
    )
```

## Multi-Tenancy

All operations automatically scope to tenant via the `get_tenant` dependency. Cross-tenant access is prevented at the store level.

## Related RFCs

| RFC      | Title            | Relationship     |
| -------- | ---------------- | ---------------- |
| RFC 7643 | SCIM Core Schema | Resource schemas |
| RFC 7642 | SCIM Definitions | Concepts         |

## Output Format

Provide:

- REST API implementations
- Filter parsing and evaluation
- Bulk operation handling
- Tenant isolation patterns

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…