Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Each plugin has its own version line, pinned by URL + SHA-256 in `connectors/<id
> Touching an engine? Read `.claude/patterns/engine-plugin.md` first, and
> `.claude/patterns/engine-fanout.md` before adding any `core.api` enum value.

AccessFlow ships as a single open-source product under Apache 2.0. Authentication uses JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab; a generic `OIDC` provider type covers other IdPs — Keycloak, Auth0, Okta, Authentik, Zitadel — with admin-editable endpoint URLs persisted on the `oauth2_config` row).
AccessFlow ships as a single open-source product under Apache 2.0. Authentication uses JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab; a generic `OIDC` provider type covers other IdPs — Keycloak, Auth0, Okta, Authentik, Zitadel — with admin-editable endpoint URLs persisted on the `oauth2_config` row). User and group lifecycle can be IdP-driven over SCIM 2.0 (`scim` module, `/scim/v2` with per-org bearer tokens — #621).

**Full design docs:** `docs/` — read them before implementing any feature. The authoritative references are:
- `docs/02-architecture.md` — system architecture and request flow
Expand Down Expand Up @@ -145,6 +145,9 @@ com.bablsoft.accessflow/
├── discovery/ # Automated sensitive-data discovery (AF-623): DiscoveryScanJob samples column data via the engine sampling path, regex+checksum detectors (email, PAN+Luhn, SSN, IBAN, phone) + optional fail-safe AI pass propose classification tags an admin confirms (AF-447 derivation) or dismisses
│ ├── api/
│ └── internal/ # config, persistence, detect (pure detectors), scheduled, web
├── scim/ # SCIM 2.0 provisioning server (#621): /scim/v2 Users+Groups behind a per-org bearer-token filter chain (@Order(0), SCIM error envelope), attribute-mapping config, show-once tokens; deactivation fans out via core.events.UserDeactivatedEvent (security revokes sessions, access revokes JIT grants)
│ ├── api/
│ └── internal/ # config (own SecurityFilterChain), persistence, protocol (wire records, filter/patch parsing), web (scim + admin controllers)
└── mcp/ # Spring AI stateless MCP server — @Tool callbacks for AI agents
├── api/
└── internal/
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<a href="docs/">Design Docs</a>
</p>

AccessFlow sits as a full query proxy in front of your databases — the relational engines PostgreSQL, MySQL, MariaDB, Oracle, and Microsoft SQL Server are supported out of the box via a declarative **connector catalog** (additional engines such as ClickHouse install with one click), any other JDBC-compatible engine can be added by uploading its driver JAR, the NoSQL document engines **MongoDB** and **Couchbase** (SQL++), the NoSQL key-value engine **Redis**, the NoSQL wide-column engines **Apache Cassandra** (CQL) and **ScyllaDB** (CQL-compatible), the NoSQL search engines **Elasticsearch** and **OpenSearch**, the NoSQL key-value engine **Amazon DynamoDB** (PartiQL), the NoSQL graph engine **Neo4j** (Cypher over Bolt), and the cloud data warehouses **Snowflake**, **Google BigQuery** (GoogleSQL), and **Databricks SQL** install the same way through on-demand native engine plugins. The catalog separates the **SQL** (relational) family, the cloud **data-warehouse** family, and the **NoSQL** umbrella of native engine-managed connectors. Every query a user submits — SQL, a MongoDB shell / JSON command, a Couchbase SQL++ statement, a Redis command, a Cassandra/ScyllaDB CQL statement, an Elasticsearch/OpenSearch query, a DynamoDB PartiQL statement, a Neo4j Cypher statement, or a Snowflake / BigQuery / Databricks warehouse SQL statement — is parsed, classified, optionally analyzed by AI, and routed through a configurable human-approval workflow before it ever reaches live data. The same governance extends beyond databases: outbound **REST, SOAP, GraphQL, and gRPC** calls against registered API connectors run through that identical pipeline — AI risk scoring, attribute-based routing, multi-stage approval — with response masking and immutable, downloadable response snapshots. Every request, decision, and execution is captured in a tamper-evident metadata audit log. Authentication is JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab). AccessFlow ships as a single open-source product under Apache 2.0 and is designed to run entirely inside your own infrastructure.
AccessFlow sits as a full query proxy in front of your databases — the relational engines PostgreSQL, MySQL, MariaDB, Oracle, and Microsoft SQL Server are supported out of the box via a declarative **connector catalog** (additional engines such as ClickHouse install with one click), any other JDBC-compatible engine can be added by uploading its driver JAR, the NoSQL document engines **MongoDB** and **Couchbase** (SQL++), the NoSQL key-value engine **Redis**, the NoSQL wide-column engines **Apache Cassandra** (CQL) and **ScyllaDB** (CQL-compatible), the NoSQL search engines **Elasticsearch** and **OpenSearch**, the NoSQL key-value engine **Amazon DynamoDB** (PartiQL), the NoSQL graph engine **Neo4j** (Cypher over Bolt), and the cloud data warehouses **Snowflake**, **Google BigQuery** (GoogleSQL), and **Databricks SQL** install the same way through on-demand native engine plugins. The catalog separates the **SQL** (relational) family, the cloud **data-warehouse** family, and the **NoSQL** umbrella of native engine-managed connectors. Every query a user submits — SQL, a MongoDB shell / JSON command, a Couchbase SQL++ statement, a Redis command, a Cassandra/ScyllaDB CQL statement, an Elasticsearch/OpenSearch query, a DynamoDB PartiQL statement, a Neo4j Cypher statement, or a Snowflake / BigQuery / Databricks warehouse SQL statement — is parsed, classified, optionally analyzed by AI, and routed through a configurable human-approval workflow before it ever reaches live data. The same governance extends beyond databases: outbound **REST, SOAP, GraphQL, and gRPC** calls against registered API connectors run through that identical pipeline — AI risk scoring, attribute-based routing, multi-stage approval — with response masking and immutable, downloadable response snapshots. Every request, decision, and execution is captured in a tamper-evident metadata audit log. Authentication is JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab), and SCIM 2.0 provisioning lets the identity provider drive user & group lifecycle end to end. AccessFlow ships as a single open-source product under Apache 2.0 and is designed to run entirely inside your own infrastructure.

---

Expand Down Expand Up @@ -104,7 +104,7 @@ A glance at the day-to-day flows engineers and approvers actually use.
- **Notifications** — Email (SMTP), Slack, Discord, Telegram, Microsoft Teams, PagerDuty, and HMAC-signed outbound webhooks with retry policy. **ServiceNow & Jira ticketing**: auto-create an incident/issue when a query is rejected, escalated, or times out awaiting review, with linked tickets on the query detail page and signed inbound webhooks syncing ticket status back — a ticket resolution can even approve/reject the pending query (bi-directional sync).
- **Slack approve/reject** — a configured Slack app adds **Approve** / **Reject** buttons to review-request messages; the decision runs through the same self-approval and RBAC guards as the REST API (HMAC-verified Interactive Components).
- **Mobile approvals (PWA) with one-tap push** — install AccessFlow as a Progressive Web App with an offline-capable review queue, and get **Web Push** notifications when a query needs your approval. Approve or reject in one tap — the decision only commits after a **step-up re-verification** (password, or TOTP when 2FA is on), and the self-approval guard is enforced server-side on every channel.
- **Identity & SSO** — JWT access tokens (15 min) + HttpOnly refresh cookies, optional SAML 2.0 SSO, OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab plus a generic `OIDC` provider for other IdPs (Keycloak, Auth0, Okta, Authentik, Zitadel), password reset and user-invitation flows.
- **Identity & SSO** — JWT access tokens (15 min) + HttpOnly refresh cookies, optional SAML 2.0 SSO, OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab plus a generic `OIDC` provider for other IdPs (Keycloak, Auth0, Okta, Authentik, Zitadel), password reset and user-invitation flows, and SCIM 2.0 provisioning so the IdP drives user & group lifecycle (create, update, deactivate, group sync — Okta / Entra ID / Keycloak / OneLogin).
- **Custom roles (RBAC)** — compose org-scoped roles from a fixed catalog of functional permissions (submit SELECT/DML/DDL, review queries / access requests / API calls, manage datasources, view the audit log, …) via a permission-matrix UI, and assign them alongside the five immutable built-in roles — e.g. a reviewer who can approve queries but not manage users. Enforcement is permission-based end to end (JWT permission claims → `PERM_*` authorities → UI gating), and role-targeted policies (masking reveals, row security, routing, approver rules) match custom roles by name.
- **Multi-tenant organization management** — a single deployment hosts one or more fully-isolated organizations (every entity is scoped by org, always derived from the JWT). A super-admin (`platform_admin`) manages tenants across the cluster — create, edit, disable / enable — with **per-org quotas** (`max_datasources`, `max_users`, `max_queries_per_day`; a breach returns `409 QUOTA_EXCEEDED`) and a disabled-org kill-switch that blocks login and requests immediately.
- **MCP server** — built-in Spring AI MCP server exposes a stateless tool surface so external AI agents can submit queries through the same review pipeline, and also discover schemas, validate SQL without executing it, read masking- and row-security-aware sample data, monitor their queries, and review their own audit trail.
Expand Down Expand Up @@ -155,7 +155,7 @@ For the full request flow, technology stack table, and component-level diagrams,
| Client state | Zustand 5 |
| Cache & locks | Redis 8 (JWT refresh-token revocation, ShedLock locks for `@Scheduled` jobs) |
| AI backends | OpenAI, Anthropic, Ollama, any OpenAI-compatible endpoint, Hugging Face (Inference Providers router or local TGI) (admin-configurable per organization) |
| Auth | JWT RS256 + optional SAML 2.0 SSO and OAuth 2.0 / OIDC (Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, self-managed GitLab built in) |
| Auth | JWT RS256 + optional SAML 2.0 SSO and OAuth 2.0 / OIDC (Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, self-managed GitLab built in); SCIM 2.0 provisioning for IdP-driven user & group lifecycle |
| Observability | Micrometer Tracing + OpenTelemetry (OTLP export), Prometheus metrics (Actuator), pre-built Grafana dashboards, structured JSON logging |
| Deploy | Docker Compose, Helm 3 |
| Infrastructure as Code | Official Terraform / OpenTofu provider (Go, terraform-plugin-framework) + reusable GitHub Actions and a GitLab CI template |
Expand Down Expand Up @@ -258,6 +258,7 @@ accessflow/
│ │ ├── access/ # JIT time-bound access requests + grant-expiry job
│ │ ├── ai/ # Spring AI adapters (OpenAI / Anthropic / Ollama / Hugging Face)
│ │ ├── security/ # JWT, Spring Security filters, SAML 2.0 SSO
│ │ ├── scim/ # SCIM 2.0 provisioning server (IdP-driven user/group lifecycle)
│ │ ├── notifications/ # Email / Slack / Webhook / Discord / Telegram / MS Teams / PagerDuty / ServiceNow / Jira dispatchers
│ │ ├── audit/ # INSERT-only, HMAC-chained audit log
│ │ ├── compliance/ # Compliance reports + signed PDF/CSV exports (AF-459)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.bablsoft.accessflow.access.internal;

import com.bablsoft.accessflow.access.api.AccessGrantStatus;
import com.bablsoft.accessflow.access.internal.persistence.repo.AccessGrantRequestRepository;
import com.bablsoft.accessflow.core.events.UserDeactivatedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;

/**
* Revokes every APPROVED JIT access grant of a user the moment they are deactivated, so standing
* database/API access disappears together with login. Reuses the ordinary revocation path
* ({@link AccessGrantRequestStateService#revoke}), system-attributed ({@code revokedByUserId=null}).
*
* <p>Per-row failures are swallowed so one broken grant cannot block the rest of the fan-out;
* {@code revoke} itself is idempotent (non-APPROVED rows are a no-op) and tolerates permissions
* that were already removed out-of-band.
*/
@Component
@RequiredArgsConstructor
@Slf4j
class UserDeactivationGrantRevoker {

private final AccessGrantRequestRepository requestRepository;
private final AccessGrantRequestStateService stateService;

@ApplicationModuleListener
void onUserDeactivated(UserDeactivatedEvent event) {
var grantIds = requestRepository.findIdsByRequesterIdAndStatus(
event.userId(), AccessGrantStatus.APPROVED);
for (var grantId : grantIds) {
try {
stateService.revoke(grantId, null);
} catch (RuntimeException ex) {
log.error("Failed to revoke access grant {} for deactivated user {}",
grantId, event.userId(), ex);
}
}
if (!grantIds.isEmpty()) {
log.info("Revoked {} active access grant(s) for deactivated user {}",
grantIds.size(), event.userId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ List<AccessGrantRequestEntity> findAllByOrganizationIdAndStatusOrderByCreatedAtA
List<UUID> findIdsByStatusAndExpiresAtBefore(@Param("status") AccessGrantStatus status,
@Param("now") Instant now);

@Query("select a.id from AccessGrantRequestEntity a "
+ "where a.requesterId = :requesterId and a.status = :status")
List<UUID> findIdsByRequesterIdAndStatus(@Param("requesterId") UUID requesterId,
@Param("status") AccessGrantStatus status);

List<AccessGrantRequestEntity>
findAllByOrganizationIdAndRequesterIdAndDatasourceIdAndStatusAndPreApproveQueriesTrueAndExpiresAtAfter(
UUID organizationId, UUID requesterId, UUID datasourceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,5 +172,14 @@ public enum AuditAction {

DISCOVERY_SCAN_COMPLETED,
DISCOVERY_FINDING_CONFIRMED,
DISCOVERY_FINDING_DISMISSED
DISCOVERY_FINDING_DISMISSED,

SCIM_CONFIG_UPDATED,
SCIM_TOKEN_CREATED,
SCIM_TOKEN_REVOKED,
SCIM_USER_PROVISIONED,
SCIM_USER_UPDATED,
SCIM_USER_DEACTIVATED,
SCIM_GROUP_SYNCED,
SCIM_GROUP_DELETED
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ public enum AuditResourceType {
DELETION_REQUEST("deletion_request"),
REQUEST_GROUP("request_group"),
QUERY_TICKET("query_ticket"),
DISCOVERY_FINDING("discovery_finding");
DISCOVERY_FINDING("discovery_finding"),
SCIM_CONFIG("scim_config"),
SCIM_TOKEN("scim_token");

private final String dbValue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
public enum AuthProviderType {
LOCAL,
SAML,
OAUTH2
OAUTH2,
/** Provisioned by an identity provider over SCIM 2.0 (#621); no password, signs in via SSO. */
SCIM
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bablsoft.accessflow.core.api;

import java.util.UUID;

/**
* Create command for an externally provisioned (SCIM, #621) user: no password, provider
* {@link AuthProviderType#SCIM}, role fixed to the org's configured default system role.
*/
public record CreateExternalUserCommand(
UUID organizationId,
String email,
String displayName,
String scimExternalId,
UserRoleType defaultRole
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@
public record CreateUserGroupCommand(
UUID organizationId,
String name,
String description
) {}
String description,
String scimExternalId
) {
/** Convenience constructor for callers that predate the SCIM external id (#621). */
public CreateUserGroupCommand(UUID organizationId, String name, String description) {
this(organizationId, name, description, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.bablsoft.accessflow.core.api;

import java.util.List;

/**
* An offset-based page (#621). Unlike {@link PageResponse}, the window starts at an arbitrary
* zero-based {@code offset} rather than a page boundary — SCIM's {@code startIndex} is 1-based
* and not required to be page-aligned.
*/
public record DirectoryPage<T>(List<T> content, long totalResults) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.bablsoft.accessflow.core.api;

/** Another user in the organization already carries this SCIM externalId (#621). */
public final class ExternalIdAlreadyExistsException extends UserAdminException {

public ExternalIdAlreadyExistsException(String externalId) {
super("User already exists with external id: " + externalId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.bablsoft.accessflow.core.api;

import java.util.Optional;
import java.util.UUID;

/**
* System-actor user primitives for external directory provisioning (SCIM, #621). Unlike
* {@link UserAdminService} there is no acting user: the self-deactivation and self-demotion
* guards do not apply, and the caller (the scim module) is responsible for authenticating the
* organization the operations are scoped to.
*
* <p>Deactivation (active {@code true -> false}) publishes
* {@code core.events.UserDeactivatedEvent} exactly like the admin paths.
*/
public interface ExternalUserDirectoryService {

/**
* Create an externally provisioned user.
*
* @throws EmailAlreadyExistsException when the email exists anywhere (emails are
* globally unique across organizations)
* @throws ExternalIdAlreadyExistsException when the externalId is taken in this org
* @throws QuotaExceededException when the org's user quota is exhausted
*/
UserView createExternal(CreateExternalUserCommand command);

/**
* Partially update an externally managed user. Only SCIM-owned attributes are touched.
*
* @throws UserNotFoundException when the user is not in this organization
* @throws EmailAlreadyExistsException when a changed email collides globally
* @throws ExternalIdAlreadyExistsException when a changed externalId collides in this org
* @throws QuotaExceededException when reactivation would exceed the user quota
*/
UserView updateExternal(UUID organizationId, UUID userId, UpdateExternalUserCommand command);

Optional<UserView> findById(UUID organizationId, UUID userId);

Optional<UserView> findByEmail(UUID organizationId, String email);

Optional<UserView> findByExternalId(UUID organizationId, String scimExternalId);

/** Offset-based listing ordered by creation time then id; offset is zero-based. */
DirectoryPage<UserView> list(UUID organizationId, int offset, int limit);
}
Loading
Loading