Initial commit — Clearview v0.1.0
Full application including FastAPI backend, PostgreSQL data model, background scan worker, multi-tenant support, certificate authentication, SharePoint REST scanner with hierarchical deduplication, SharingLinks classification and post-scan resolve, Excel export, site filter in job details, role name normalisation, and updated documentation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
b8446c0665
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.files
|
||||
.last-branch
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.codex
|
||||
97
README.md
Normal file
97
README.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Clearview
|
||||
|
||||
SharePoint permission deviation scanner for multiple customer tenants.
|
||||
|
||||
Clearview scans SharePoint sites down to folder and file level and reports only permissions that deviate from the root permissions of each site. Designed to manage and monitor multiple customer tenants from a single instance.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. Add a customer tenant (name, Azure tenant ID, client ID)
|
||||
2. Generate a certificate — upload the public `.cer` to the Azure app registration
|
||||
3. Submit site URLs manually or via a Microsoft Sites CSV export
|
||||
4. Clearview scans asynchronously and reports permission deviations
|
||||
|
||||
Only permissions that are **added** relative to the site root are reported (`delta_type=added`). No NTFS or filesystem permissions are used.
|
||||
|
||||
Deviations are **deduplicated hierarchically**: if a principal already has a deviation at a library or folder level, individual files below that level are suppressed.
|
||||
|
||||
---
|
||||
|
||||
## Job Details
|
||||
|
||||
After a scan completes, the **Selected Job Details** panel provides:
|
||||
|
||||
- **Site filter** — narrow targets and deviations to a single site
|
||||
- **Export Excel** — download a `.xlsx` file with Targets and Deviations sheets, sorted by Site URL → Object URL → Principal
|
||||
- **Resolve Sharing Links** — fetch the actual recipients of sharing links post-scan (Anonymous and Flexible types resolved by default)
|
||||
|
||||
### SharingLinks colour coding
|
||||
|
||||
| Type | Risk | Colour |
|
||||
|---|---|---|
|
||||
| `Anonymous*` | Critical | Red |
|
||||
| `Flexible` | High | Orange |
|
||||
| `Organization*` | Low | Blue |
|
||||
| `Direct*` | Low | Green |
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker + Docker Compose (or Portainer)
|
||||
|
||||
### Stack
|
||||
|
||||
Copy `stack/.env` and `stack/docker-compose.yml` to your deployment location and adjust `.env` as needed. The `.env` file is self-documented.
|
||||
|
||||
Start the stack:
|
||||
|
||||
```bash
|
||||
docker compose -f stack/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Clearview is available at `http://<host>:<CLEARVIEW_PORT>`.
|
||||
Adminer (database inspector) is available at `http://<host>:<ADMINER_PORT>`.
|
||||
|
||||
---
|
||||
|
||||
## Azure app setup (per tenant)
|
||||
|
||||
Each customer tenant requires a dedicated Azure app registration with SharePoint access.
|
||||
|
||||
1. **Azure Portal** → Entra ID → App registrations → New registration
|
||||
- Name: e.g. `Clearview Scan App`
|
||||
- Supported account types: Single tenant
|
||||
2. Copy the **Directory (tenant) ID** and **Application (client) ID**
|
||||
3. **API permissions** → Add → SharePoint → Application permissions → `Sites.FullControl.All` → Grant admin consent
|
||||
4. Add the tenant in Clearview (name, tenant ID, client ID)
|
||||
5. Click **Certificate** → download the `.cer` file
|
||||
6. Upload the `.cer` in Azure Portal → App registration → **Certificates & secrets → Certificates**
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./build-and-push.sh t # test build (:dev tag)
|
||||
./build-and-push.sh 1 # patch release
|
||||
./build-and-push.sh 2 # minor release
|
||||
./build-and-push.sh 3 # major release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
| Table | Description |
|
||||
|---|---|
|
||||
| `tenant_profiles` | Customer tenant credentials and certificates |
|
||||
| `scan_jobs` | Scan jobs with status and progress tracking |
|
||||
| `scan_targets` | Individual sites within a job |
|
||||
| `permission_deviations` | Detected permission deviations per target, including resolved sharing link members |
|
||||
|
||||
See `docs/TECHNICAL.md` for full architecture documentation.
|
||||
220
build-and-push.sh
Executable file
220
build-and-push.sh
Executable file
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DOCKER_REGISTRY="gitea.oskamp.info"
|
||||
DOCKER_NAMESPACE="ivooskamp"
|
||||
|
||||
VERSION_FILE="version.txt"
|
||||
START_VERSION="v0.1.0"
|
||||
CONTAINERS_DIR="containers"
|
||||
LAST_BRANCH_FILE=".last-branch"
|
||||
|
||||
BUMP="${1:-}"
|
||||
if [[ -z "${BUMP}" ]]; then
|
||||
echo "Select bump type: [1] patch, [2] minor, [3] major, [t] test (default: t)"
|
||||
read -r BUMP
|
||||
BUMP="${BUMP:-t}"
|
||||
fi
|
||||
|
||||
if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then
|
||||
echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_version() {
|
||||
if [[ -f "$VERSION_FILE" ]]; then
|
||||
tr -d ' \t\n\r' < "$VERSION_FILE"
|
||||
else
|
||||
echo "$START_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
write_version() {
|
||||
echo "$1" > "$VERSION_FILE"
|
||||
}
|
||||
|
||||
bump_version() {
|
||||
local cur="$1"
|
||||
local kind="$2"
|
||||
local core="${cur#v}"
|
||||
IFS='.' read -r MA MI PA <<< "$core"
|
||||
case "$kind" in
|
||||
1) PA=$((PA + 1));;
|
||||
2) MI=$((MI + 1)); PA=0;;
|
||||
3) MA=$((MA + 1)); MI=0; PA=0;;
|
||||
*) echo "[ERROR] Unknown bump kind"; exit 1;;
|
||||
esac
|
||||
echo "v${MA}.${MI}.${PA}"
|
||||
}
|
||||
|
||||
check_docker_ready() {
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[ERROR] Docker daemon not reachable. Is Docker running and do you have permission to use it?"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_registry_login() {
|
||||
local cfg="${HOME}/.docker/config.json"
|
||||
if [[ ! -f "$cfg" ]]; then
|
||||
echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "\"${DOCKER_REGISTRY}\"" "$cfg"; then
|
||||
echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_repo_component() {
|
||||
local comp="$1"
|
||||
if [[ ! "$comp" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
|
||||
echo "[ERROR] Invalid repository component '$comp'."
|
||||
echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_tag() {
|
||||
local tag="$1"
|
||||
local len="${#tag}"
|
||||
if (( len < 1 || len > 128 )); then
|
||||
echo "[ERROR] Invalid tag length ($len). Must be between 1 and 128 characters."
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
|
||||
echo "[ERROR] Invalid tag '$tag'. Allowed: [A-Za-z0-9_.-], must start with alphanumeric or underscore."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! -d ".git" ]]; then
|
||||
echo "[ERROR] Not a git repository (.git missing)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$CONTAINERS_DIR" ]]; then
|
||||
echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}/<service>/ with a Dockerfile."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_docker_ready
|
||||
ensure_registry_login
|
||||
validate_repo_component "$DOCKER_NAMESPACE"
|
||||
|
||||
DETECTED_BRANCH="$(git branch --show-current 2>/dev/null || true)"
|
||||
if [[ -z "$DETECTED_BRANCH" ]]; then
|
||||
DETECTED_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$DETECTED_BRANCH" ]]; then
|
||||
DETECTED_BRANCH="main"
|
||||
fi
|
||||
|
||||
UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "origin/$DETECTED_BRANCH")"
|
||||
HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
LAST_BRANCH_FILE_PATH="$(pwd)/$LAST_BRANCH_FILE"
|
||||
|
||||
echo "[INFO] Repo: $(pwd)"
|
||||
echo "[INFO] Current branch: $DETECTED_BRANCH"
|
||||
echo "[INFO] Upstream: $UPSTREAM_REF"
|
||||
echo "[INFO] HEAD (sha): $HEAD_SHA"
|
||||
|
||||
CURRENT_VERSION="$(read_version)"
|
||||
NEW_VERSION="$CURRENT_VERSION"
|
||||
DO_TAG_AND_BUMP=true
|
||||
|
||||
if [[ "$BUMP" == "t" ]]; then
|
||||
echo "[INFO] Test build: keeping version $CURRENT_VERSION; will only update :dev."
|
||||
DO_TAG_AND_BUMP=false
|
||||
else
|
||||
NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")"
|
||||
echo "[INFO] New version: $NEW_VERSION"
|
||||
fi
|
||||
|
||||
if $DO_TAG_AND_BUMP; then
|
||||
validate_tag "$NEW_VERSION"
|
||||
validate_tag "latest"
|
||||
fi
|
||||
validate_tag "dev"
|
||||
|
||||
if $DO_TAG_AND_BUMP; then
|
||||
echo "[INFO] Writing $NEW_VERSION to $VERSION_FILE"
|
||||
write_version "$NEW_VERSION"
|
||||
|
||||
echo "[INFO] Git add + commit (branch: $DETECTED_BRANCH)"
|
||||
git add "$VERSION_FILE"
|
||||
git commit -m "Release $NEW_VERSION on branch $DETECTED_BRANCH (bump type $BUMP)"
|
||||
|
||||
echo "[INFO] Git tag $NEW_VERSION"
|
||||
git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION"
|
||||
|
||||
echo "[INFO] Git push + tags"
|
||||
git push origin "$DETECTED_BRANCH"
|
||||
git push --tags
|
||||
else
|
||||
echo "[INFO] Skipping commit/tagging (test build)."
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
services=( "$CONTAINERS_DIR"/* )
|
||||
if [[ ${#services[@]} -eq 0 ]]; then
|
||||
echo "[ERROR] No services found under $CONTAINERS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILT_IMAGES=()
|
||||
|
||||
for svc_path in "${services[@]}"; do
|
||||
[[ -d "$svc_path" ]] || continue
|
||||
svc="$(basename "$svc_path")"
|
||||
dockerfile="$svc_path/Dockerfile"
|
||||
|
||||
validate_repo_component "$svc"
|
||||
|
||||
if [[ ! -f "$dockerfile" ]]; then
|
||||
echo "[WARNING] Skipping '${svc}': Dockerfile not found in ${svc_path}"
|
||||
continue
|
||||
fi
|
||||
|
||||
IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}"
|
||||
|
||||
if $DO_TAG_AND_BUMP; then
|
||||
echo "============================================================"
|
||||
echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, dev, latest"
|
||||
echo "============================================================"
|
||||
docker build \
|
||||
-t "${IMAGE_BASE}:${NEW_VERSION}" \
|
||||
-t "${IMAGE_BASE}:dev" \
|
||||
-t "${IMAGE_BASE}:latest" \
|
||||
"$svc_path"
|
||||
|
||||
docker push "${IMAGE_BASE}:${NEW_VERSION}"
|
||||
docker push "${IMAGE_BASE}:dev"
|
||||
docker push "${IMAGE_BASE}:latest"
|
||||
|
||||
BUILT_IMAGES+=("${IMAGE_BASE}:${NEW_VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest")
|
||||
else
|
||||
echo "============================================================"
|
||||
echo "[INFO] Test build ${svc} -> tag: dev"
|
||||
echo "============================================================"
|
||||
docker build -t "${IMAGE_BASE}:dev" "$svc_path"
|
||||
docker push "${IMAGE_BASE}:dev"
|
||||
BUILT_IMAGES+=("${IMAGE_BASE}:dev")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$DETECTED_BRANCH" > "$LAST_BRANCH_FILE_PATH"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "[SUMMARY] Build & push complete (branch: $DETECTED_BRANCH)"
|
||||
if $DO_TAG_AND_BUMP; then
|
||||
echo "[INFO] Release version: $NEW_VERSION"
|
||||
else
|
||||
echo "[INFO] Test build (no version bump)"
|
||||
fi
|
||||
echo "[INFO] Images pushed:"
|
||||
for img in "${BUILT_IMAGES[@]}"; do
|
||||
echo " - $img"
|
||||
done
|
||||
echo "============================================================"
|
||||
17
containers/clearview/Dockerfile
Normal file
17
containers/clearview/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src ./src
|
||||
COPY site ./site
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["uvicorn", "clearview_app.main:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
9
containers/clearview/requirements.txt
Normal file
9
containers/clearview/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
python-multipart==0.0.12
|
||||
requests==2.32.3
|
||||
cryptography==44.0.2
|
||||
msal==1.32.0
|
||||
openpyxl==3.1.5
|
||||
946
containers/clearview/site/app.js
Normal file
946
containers/clearview/site/app.js
Normal file
@ -0,0 +1,946 @@
|
||||
(function () {
|
||||
const state = {
|
||||
selectedJobId: null,
|
||||
selectedJobData: null,
|
||||
refreshTimer: null,
|
||||
tenants: [],
|
||||
};
|
||||
|
||||
const els = {
|
||||
// Cert block
|
||||
certBlock: document.getElementById('certBlock'),
|
||||
certPem: document.getElementById('certPem'),
|
||||
downloadCertBtn: document.getElementById('downloadCertBtn'),
|
||||
copyCertBtn: document.getElementById('copyCertBtn'),
|
||||
closeCertBtn: document.getElementById('closeCertBtn'),
|
||||
// Tenant panel
|
||||
addTenantBtn: document.getElementById('addTenantBtn'),
|
||||
addTenantForm: document.getElementById('addTenantForm'),
|
||||
tenantSetupAutomated: document.getElementById('tenantSetupAutomated'),
|
||||
tenantSetupManual: document.getElementById('tenantSetupManual'),
|
||||
onboardingForm: document.getElementById('onboardingForm'),
|
||||
connectMicrosoftBtn: document.getElementById('connectMicrosoftBtn'),
|
||||
connectedTenantId: document.getElementById('connectedTenantId'),
|
||||
scanAppDisplayName: document.getElementById('scanAppDisplayName'),
|
||||
newTenantName: document.getElementById('newTenantName'),
|
||||
newTenantTenantId: document.getElementById('newTenantTenantId'),
|
||||
newTenantClientId: document.getElementById('newTenantClientId'),
|
||||
newTenantClientSecret: document.getElementById('newTenantClientSecret'),
|
||||
saveTenantBtn: document.getElementById('saveTenantBtn'),
|
||||
cancelTenantBtn: document.getElementById('cancelTenantBtn'),
|
||||
tenantsTableBody: document.getElementById('tenantsTableBody'),
|
||||
tenantFeedback: document.getElementById('tenantFeedback'),
|
||||
// Scan panel
|
||||
scanTenantSelect: document.getElementById('scanTenantSelect'),
|
||||
manualCredentialsBlock: document.getElementById('manualCredentialsBlock'),
|
||||
tenantId: document.getElementById('tenantId'),
|
||||
clientId: document.getElementById('clientId'),
|
||||
clientSecret: document.getElementById('clientSecret'),
|
||||
manualScanForm: document.getElementById('manualScanForm'),
|
||||
csvScanForm: document.getElementById('csvScanForm'),
|
||||
manualUrls: document.getElementById('manualUrls'),
|
||||
manualSkipDefaults: document.getElementById('manualSkipDefaults'),
|
||||
csvFile: document.getElementById('csvFile'),
|
||||
csvSkipDefaults: document.getElementById('csvSkipDefaults'),
|
||||
submitFeedback: document.getElementById('submitFeedback'),
|
||||
// Jobs panel
|
||||
refreshJobsBtn: document.getElementById('refreshJobsBtn'),
|
||||
jobTenantFilter: document.getElementById('jobTenantFilter'),
|
||||
jobsTableBody: document.getElementById('jobsTableBody'),
|
||||
jobAutoRefresh: document.getElementById('jobAutoRefresh'),
|
||||
// Job detail panel
|
||||
targetsTableBody: document.getElementById('targetsTableBody'),
|
||||
deviationsTableBody: document.getElementById('deviationsTableBody'),
|
||||
selectedJobId: document.getElementById('selectedJobId'),
|
||||
jobSummary: document.getElementById('jobSummary'),
|
||||
jobActivity: document.getElementById('jobActivity'),
|
||||
jobSiteFilter: document.getElementById('jobSiteFilter'),
|
||||
exportJobBtn: document.getElementById('exportJobBtn'),
|
||||
sharingLinksResolveBlock: document.getElementById('sharingLinksResolveBlock'),
|
||||
sharingLinksTypes: document.getElementById('sharingLinksTypes'),
|
||||
resolveSharingLinksBtn: document.getElementById('resolveSharingLinksBtn'),
|
||||
resolveFeedback: document.getElementById('resolveFeedback'),
|
||||
// Hero stats
|
||||
statTenants: document.getElementById('statTenants'),
|
||||
statJobs: document.getElementById('statJobs'),
|
||||
statRunning: document.getElementById('statRunning'),
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText;
|
||||
try {
|
||||
const data = await response.json();
|
||||
detail = data.detail || JSON.stringify(data);
|
||||
} catch (err) {
|
||||
void err;
|
||||
}
|
||||
throw new Error(detail || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function showFeedback(el, message, type) {
|
||||
el.textContent = message;
|
||||
el.className = 'feedback ' + (type || '');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'completed' ? 'ok'
|
||||
: status === 'running' ? 'warn'
|
||||
: status === 'queued' ? 'info'
|
||||
: status === 'completed_with_errors' ? 'high'
|
||||
: status === 'failed' ? 'critical'
|
||||
: status === 'cancelled' ? 'critical'
|
||||
: 'info';
|
||||
return '<span class="risk ' + cls + '">' + status + '</span>';
|
||||
}
|
||||
|
||||
function abbrev(str, max) {
|
||||
if (!str) return '-';
|
||||
return str.length > max ? str.slice(0, max) + '\u2026' : str;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tenant management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function loadTenants() {
|
||||
const tenants = await requestJson('/api/tenants');
|
||||
state.tenants = tenants;
|
||||
renderTenantsTable();
|
||||
populateTenantDropdowns();
|
||||
}
|
||||
|
||||
function renderTenantsTable() {
|
||||
els.statTenants.textContent = String(state.tenants.length);
|
||||
|
||||
if (!state.tenants.length) {
|
||||
els.tenantsTableBody.innerHTML = '<tr><td colspan="5">No tenants configured yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.tenantsTableBody.innerHTML = state.tenants.map(function (t) {
|
||||
var authCell;
|
||||
if (t.has_certificate) {
|
||||
var expiry = t.cert_expires_at ? ' (exp. ' + formatDate(t.cert_expires_at).split(',')[0] + ')' : '';
|
||||
authCell = '<span class="tenant-tag">cert' + escHtml(expiry) + '</span>';
|
||||
} else if (t.client_secret !== undefined) {
|
||||
authCell = '<span style="color:var(--cv-text-secondary);font-size:0.82rem">secret</span>';
|
||||
} else {
|
||||
authCell = '<span class="risk critical">none</span>';
|
||||
}
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><strong>' + escHtml(t.name) + '</strong></td>' +
|
||||
'<td><code>' + abbrev(t.tenant_id, 36) + '</code></td>' +
|
||||
'<td><code>' + abbrev(t.client_id, 36) + '</code></td>' +
|
||||
'<td>' + authCell + '</td>' +
|
||||
'<td>' + formatDate(t.created_at) + '</td>' +
|
||||
'<td>' +
|
||||
'<div style="display:flex;gap:0.4rem;flex-wrap:wrap">' +
|
||||
'<button class="btn btn-outline btn-small" data-tenant-cert="' + escHtml(t.id) + '" title="Generate or replace certificate">Certificate</button>' +
|
||||
'<button class="btn btn-outline btn-small" data-tenant-scan="' + escHtml(t.id) + '" title="Start a scan for this tenant">Scan</button>' +
|
||||
'<button class="btn btn-outline btn-small" data-tenant-delete="' + escHtml(t.id) + '" title="Remove this tenant profile">Delete</button>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
|
||||
els.tenantsTableBody.querySelectorAll('[data-tenant-cert]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.getAttribute('data-tenant-cert');
|
||||
const tenant = state.tenants.find(function (t) { return t.id === id; });
|
||||
const name = tenant ? tenant.name : id;
|
||||
const msg = tenant && tenant.has_certificate
|
||||
? 'Replace the existing certificate for "' + name + '"? The old certificate will stop working in Azure.'
|
||||
: 'Generate a certificate for "' + name + '"?';
|
||||
if (!window.confirm(msg)) return;
|
||||
generateCertificate(id);
|
||||
});
|
||||
});
|
||||
|
||||
els.tenantsTableBody.querySelectorAll('[data-tenant-scan]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.getAttribute('data-tenant-scan');
|
||||
// Pre-select this tenant in the scan form
|
||||
els.scanTenantSelect.value = id;
|
||||
onScanTenantChange();
|
||||
// Scroll to scan panel
|
||||
els.manualScanForm.closest('.panel').scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
els.tenantsTableBody.querySelectorAll('[data-tenant-delete]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.getAttribute('data-tenant-delete');
|
||||
const tenant = state.tenants.find(function (t) { return t.id === id; });
|
||||
const name = tenant ? tenant.name : id;
|
||||
if (!window.confirm('Delete tenant "' + name + '"? Associated jobs will remain but lose their tenant link.')) {
|
||||
return;
|
||||
}
|
||||
deleteTenant(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function populateTenantDropdowns() {
|
||||
// Scan tenant select
|
||||
const scanVal = els.scanTenantSelect.value;
|
||||
els.scanTenantSelect.innerHTML =
|
||||
'<option value="">-- Select a tenant --</option>' +
|
||||
state.tenants.map(function (t) {
|
||||
return '<option value="' + escHtml(t.id) + '">' + escHtml(t.name) + '</option>';
|
||||
}).join('') +
|
||||
'<option value="__manual__">Manual credentials...</option>';
|
||||
if (scanVal) {
|
||||
els.scanTenantSelect.value = scanVal;
|
||||
}
|
||||
|
||||
// Job tenant filter select
|
||||
const filterVal = els.jobTenantFilter.value;
|
||||
els.jobTenantFilter.innerHTML =
|
||||
'<option value="">All tenants</option>' +
|
||||
state.tenants.map(function (t) {
|
||||
return '<option value="' + escHtml(t.id) + '">' + escHtml(t.name) + '</option>';
|
||||
}).join('');
|
||||
if (filterVal) {
|
||||
els.jobTenantFilter.value = filterVal;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCertificate(tenantId) {
|
||||
const tenant = state.tenants.find(function (t) { return t.id === tenantId; });
|
||||
const tenantName = tenant ? tenant.name.replace(/[^a-z0-9_-]/gi, '_') : 'clearview';
|
||||
showFeedback(els.tenantFeedback, 'Generating certificate...', '');
|
||||
try {
|
||||
const result = await requestJson('/api/tenants/' + encodeURIComponent(tenantId) + '/generate-certificate', {
|
||||
method: 'POST',
|
||||
});
|
||||
await loadTenants();
|
||||
showFeedback(els.tenantFeedback, 'Certificate generated (thumbprint: ' + result.thumbprint + '). Download and upload to Azure Portal.', 'ok');
|
||||
els.certPem.value = result.public_cert_pem;
|
||||
els.downloadCertBtn.setAttribute('data-filename', tenantName + '_clearview.cer');
|
||||
els.certBlock.removeAttribute('hidden');
|
||||
els.certBlock.scrollIntoView({ behavior: 'smooth' });
|
||||
} catch (err) {
|
||||
showFeedback(els.tenantFeedback, 'Certificate generation failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTenant() {
|
||||
const name = (els.newTenantName.value || '').trim();
|
||||
const tenantId = (els.newTenantTenantId.value || '').trim();
|
||||
const clientId = (els.newTenantClientId.value || '').trim();
|
||||
const clientSecret = (els.newTenantClientSecret.value || '').trim();
|
||||
|
||||
if (!name || !tenantId || !clientId) {
|
||||
showFeedback(els.tenantFeedback, 'Name, Tenant ID, and Client ID are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await requestJson('/api/tenants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, tenant_id: tenantId, client_id: clientId, client_secret: clientSecret }),
|
||||
});
|
||||
showFeedback(els.tenantFeedback, 'Tenant "' + name + '" saved.', 'ok');
|
||||
closeTenantForm();
|
||||
await loadTenants();
|
||||
} catch (err) {
|
||||
showFeedback(els.tenantFeedback, 'Save failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTenant(id) {
|
||||
try {
|
||||
await fetch('/api/tenants/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
await loadTenants();
|
||||
showFeedback(els.tenantFeedback, 'Tenant removed.', 'ok');
|
||||
} catch (err) {
|
||||
showFeedback(els.tenantFeedback, 'Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openTenantForm() {
|
||||
els.addTenantForm.removeAttribute('hidden');
|
||||
els.addTenantBtn.setAttribute('hidden', '');
|
||||
els.newTenantName.focus();
|
||||
}
|
||||
|
||||
function closeTenantForm() {
|
||||
els.addTenantForm.setAttribute('hidden', '');
|
||||
els.addTenantBtn.removeAttribute('hidden');
|
||||
els.newTenantName.value = '';
|
||||
els.newTenantTenantId.value = '';
|
||||
els.newTenantClientId.value = '';
|
||||
els.newTenantClientSecret.value = '';
|
||||
if (els.connectedTenantId) els.connectedTenantId.value = '';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scan credentials helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function onScanTenantChange() {
|
||||
const val = els.scanTenantSelect.value;
|
||||
if (val === '__manual__') {
|
||||
els.manualCredentialsBlock.removeAttribute('hidden');
|
||||
} else {
|
||||
els.manualCredentialsBlock.setAttribute('hidden', '');
|
||||
}
|
||||
}
|
||||
|
||||
function readScanAuth() {
|
||||
const val = els.scanTenantSelect.value;
|
||||
if (!val) {
|
||||
throw new Error('Select a tenant profile first.');
|
||||
}
|
||||
if (val === '__manual__') {
|
||||
const tenantId = (els.tenantId.value || '').trim();
|
||||
const clientId = (els.clientId.value || '').trim();
|
||||
const clientSecret = (els.clientSecret.value || '').trim();
|
||||
if (!tenantId || !clientId || !clientSecret) {
|
||||
throw new Error('Tenant ID, Client ID, and Client Secret are required.');
|
||||
}
|
||||
return { tenant_id: tenantId, client_id: clientId, client_secret: clientSecret };
|
||||
}
|
||||
return { tenant_profile_id: val };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Onboarding
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function createScanAppAutomatically(event) {
|
||||
event.preventDefault();
|
||||
const connectedTenantId = (els.connectedTenantId.value || '').trim();
|
||||
const displayName = (els.scanAppDisplayName.value || '').trim() || 'Clearview Scan App';
|
||||
if (!connectedTenantId) {
|
||||
showFeedback(els.tenantFeedback, 'Connect Microsoft first to load tenant id.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await requestJson('/api/onboarding/create-scan-app', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: connectedTenantId, display_name: displayName }),
|
||||
});
|
||||
els.newTenantTenantId.value = result.tenant_id;
|
||||
els.newTenantClientId.value = result.client_id;
|
||||
els.newTenantClientSecret.value = result.client_secret;
|
||||
if (!els.newTenantName.value) {
|
||||
els.newTenantName.value = displayName;
|
||||
}
|
||||
showFeedback(els.tenantFeedback, 'Scan app created. Fill in a name and click Save Tenant.', 'ok');
|
||||
} catch (err) {
|
||||
showFeedback(els.tenantFeedback, 'Auto app creation failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function startMicrosoftConnect() {
|
||||
try {
|
||||
const payload = await requestJson('/api/onboarding/microsoft/connect-url');
|
||||
if (!payload.connect_url) {
|
||||
throw new Error('Missing connect URL from server');
|
||||
}
|
||||
window.location.href = payload.connect_url;
|
||||
} catch (err) {
|
||||
showFeedback(els.tenantFeedback, 'Connect flow failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function consumeOnboardingQueryState() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get('onboarding_status');
|
||||
if (!status) return;
|
||||
|
||||
if (status === 'connected') {
|
||||
const tenantId = params.get('tenant_id') || '';
|
||||
openTenantForm();
|
||||
if (tenantId && els.newTenantTenantId) {
|
||||
els.newTenantTenantId.value = tenantId;
|
||||
}
|
||||
if (els.connectedTenantId) {
|
||||
els.connectedTenantId.value = tenantId;
|
||||
}
|
||||
showFeedback(els.tenantFeedback, 'Microsoft connected. Fill in the tenant details and save.', 'ok');
|
||||
} else if (status === 'error') {
|
||||
const message = params.get('onboarding_message') || 'Unknown onboarding error';
|
||||
showFeedback(els.tenantFeedback, 'Microsoft connect failed: ' + message, 'error');
|
||||
}
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
|
||||
}
|
||||
|
||||
async function initOnboardingSection() {
|
||||
try {
|
||||
const status = await requestJson('/api/onboarding/status');
|
||||
if (status.automated_available) {
|
||||
els.tenantSetupAutomated.removeAttribute('hidden');
|
||||
} else {
|
||||
els.tenantSetupManual.removeAttribute('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
els.tenantSetupManual.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scan job creation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function createManualJob(event) {
|
||||
event.preventDefault();
|
||||
const urls = els.manualUrls.value
|
||||
.split(/\r?\n/)
|
||||
.map(function (line) { return line.trim(); })
|
||||
.filter(Boolean);
|
||||
if (!urls.length) {
|
||||
showFeedback(els.submitFeedback, 'Enter at least one SharePoint site URL.', 'error');
|
||||
return;
|
||||
}
|
||||
let auth;
|
||||
try {
|
||||
auth = readScanAuth();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, err.message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = Object.assign({ site_urls: urls, skip_default_sites: !!els.manualSkipDefaults.checked }, auth);
|
||||
const result = await requestJson('/api/scan-jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
showFeedback(
|
||||
els.submitFeedback,
|
||||
'Job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
||||
', skipped default=' + result.skipped_default_urls.length +
|
||||
', invalid=' + result.invalid_urls.length,
|
||||
'ok'
|
||||
);
|
||||
els.manualUrls.value = '';
|
||||
state.selectedJobId = result.job.id;
|
||||
await refreshJobs();
|
||||
await refreshSelectedJob();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, 'Manual scan failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createCsvJob(event) {
|
||||
event.preventDefault();
|
||||
const file = els.csvFile.files && els.csvFile.files[0];
|
||||
if (!file) {
|
||||
showFeedback(els.submitFeedback, 'Select a CSV file first.', 'error');
|
||||
return;
|
||||
}
|
||||
let auth;
|
||||
try {
|
||||
auth = readScanAuth();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, err.message, 'error');
|
||||
return;
|
||||
}
|
||||
const skipDefaults = els.csvSkipDefaults.checked ? 'true' : 'false';
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (auth.tenant_profile_id) {
|
||||
formData.append('tenant_profile_id', auth.tenant_profile_id);
|
||||
} else {
|
||||
formData.append('tenant_id', auth.tenant_id);
|
||||
formData.append('client_id', auth.client_id);
|
||||
formData.append('client_secret', auth.client_secret);
|
||||
}
|
||||
try {
|
||||
const result = await requestJson('/api/scan-jobs/import-csv?skip_default_sites=' + skipDefaults, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
showFeedback(
|
||||
els.submitFeedback,
|
||||
'CSV job queued: ' + result.job.id + ' | accepted=' + result.accepted_urls.length +
|
||||
', skipped default=' + result.skipped_default_urls.length +
|
||||
', invalid=' + result.invalid_urls.length,
|
||||
'ok'
|
||||
);
|
||||
els.csvFile.value = '';
|
||||
state.selectedJobId = result.job.id;
|
||||
await refreshJobs();
|
||||
await refreshSelectedJob();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, 'CSV import failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Jobs list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function refreshJobs() {
|
||||
const filterTenant = els.jobTenantFilter.value;
|
||||
let url = '/api/scan-jobs?limit=50';
|
||||
if (filterTenant) {
|
||||
url += '&tenant_profile_id=' + encodeURIComponent(filterTenant);
|
||||
}
|
||||
const jobs = await requestJson(url);
|
||||
|
||||
els.statJobs.textContent = String(jobs.length);
|
||||
els.statRunning.textContent = String(jobs.filter(function (j) {
|
||||
return j.status === 'running' || j.status === 'queued';
|
||||
}).length);
|
||||
|
||||
if (!jobs.length) {
|
||||
els.jobsTableBody.innerHTML = '<tr><td colspan="8">No jobs yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.selectedJobId) {
|
||||
state.selectedJobId = jobs[0].id;
|
||||
}
|
||||
|
||||
els.jobsTableBody.innerHTML = jobs.map(function (job) {
|
||||
const progress = job.total_targets > 0
|
||||
? (job.processed_targets + '/' + job.total_targets)
|
||||
: '0/0';
|
||||
const tenantLabel = job.tenant_name
|
||||
? '<span class="tenant-tag">' + escHtml(job.tenant_name) + '</span>'
|
||||
: '<span style="color:var(--cv-text-secondary);font-size:0.82rem">manual</span>';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><code>' + job.id + '</code></td>' +
|
||||
'<td>' + tenantLabel + '</td>' +
|
||||
'<td>' + job.source_type + '</td>' +
|
||||
'<td>' + statusBadge(job.status) + '</td>' +
|
||||
'<td>' + progress + '</td>' +
|
||||
'<td>' + (job.items_scanned > 0 ? job.items_scanned : '-') + '</td>' +
|
||||
'<td>' + formatDate(job.updated_at) + '</td>' +
|
||||
'<td>' +
|
||||
'<div style="display:flex;gap:0.4rem">' +
|
||||
'<button class="btn btn-outline btn-small" data-job-inspect="' + job.id + '">Inspect</button>' +
|
||||
(job.status === 'queued' || job.status === 'running'
|
||||
? '<button class="btn btn-outline btn-small" data-job-cancel="' + job.id + '">Cancel</button>'
|
||||
: '<button class="btn btn-outline btn-small" data-job-delete="' + job.id + '">Delete</button>') +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
|
||||
els.jobsTableBody.querySelectorAll('[data-job-inspect]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
state.selectedJobId = button.getAttribute('data-job-inspect');
|
||||
refreshSelectedJob().catch(function () {
|
||||
showFeedback(els.submitFeedback, 'Failed to load selected job details.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
els.jobsTableBody.querySelectorAll('[data-job-cancel]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
const jobId = button.getAttribute('data-job-cancel');
|
||||
if (!window.confirm('Cancel job ' + jobId + '? The current target will finish before the job stops.')) return;
|
||||
cancelJob(jobId);
|
||||
});
|
||||
});
|
||||
|
||||
els.jobsTableBody.querySelectorAll('[data-job-delete]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
const jobId = button.getAttribute('data-job-delete');
|
||||
if (!window.confirm('Delete job ' + jobId + '? This also removes all targets and deviations for this job.')) return;
|
||||
deleteJob(jobId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelJob(jobId) {
|
||||
try {
|
||||
await requestJson('/api/scan-jobs/' + encodeURIComponent(jobId) + '/cancel', { method: 'POST' });
|
||||
await tick();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, 'Cancel failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJob(jobId) {
|
||||
try {
|
||||
await fetch('/api/scan-jobs/' + encodeURIComponent(jobId), { method: 'DELETE' });
|
||||
if (state.selectedJobId === jobId) {
|
||||
state.selectedJobId = null;
|
||||
state.selectedJobData = null;
|
||||
els.selectedJobId.textContent = 'No selection';
|
||||
els.jobSummary.textContent = 'Select a job to inspect targets and deviations.';
|
||||
els.targetsTableBody.innerHTML = '<tr><td colspan="4">No job selected.</td></tr>';
|
||||
els.deviationsTableBody.innerHTML = '<tr><td colspan="6">No deviation data yet.</td></tr>';
|
||||
els.jobSiteFilter.innerHTML = '<option value="">All sites</option>';
|
||||
els.exportJobBtn.setAttribute('hidden', '');
|
||||
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
||||
}
|
||||
await refreshJobs();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, 'Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobTables(job) {
|
||||
const siteFilter = els.jobSiteFilter.value;
|
||||
|
||||
const filteredTargets = siteFilter
|
||||
? job.targets.filter(function (t) { return t.site_url === siteFilter; })
|
||||
: job.targets;
|
||||
|
||||
const filteredDeviations = siteFilter
|
||||
? job.deviations.filter(function (d) { return d.site_url === siteFilter; })
|
||||
: job.deviations;
|
||||
|
||||
els.targetsTableBody.innerHTML = filteredTargets.length
|
||||
? filteredTargets.map(function (target) {
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td>' + escHtml(target.site_url) + '</td>' +
|
||||
'<td>' + statusBadge(target.status) + '</td>' +
|
||||
'<td>' + target.attempts + '</td>' +
|
||||
'<td>' + escHtml(target.error_message || '-') + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('')
|
||||
: '<tr><td colspan="4">No targets.</td></tr>';
|
||||
|
||||
els.deviationsTableBody.innerHTML = filteredDeviations.length
|
||||
? filteredDeviations.map(function (deviation) {
|
||||
const siteShort = shortSite(deviation.site_url);
|
||||
const objRel = relPath(deviation.site_url, deviation.object_url);
|
||||
const linkType = sharingLinkType(deviation.principal);
|
||||
const riskCls = sharingLinkRiskClass(linkType);
|
||||
var principalCell;
|
||||
if (linkType) {
|
||||
var badge = '<span class="risk ' + riskCls + '">' + escHtml(linkType) + '</span>';
|
||||
var members = '';
|
||||
if (deviation.resolved_members !== null && deviation.resolved_members !== undefined) {
|
||||
var memberText = deviation.resolved_members || '(public link)';
|
||||
members = '<br><span class="cell-members">' + escHtml(memberText) + '</span>';
|
||||
}
|
||||
principalCell = '<td class="col-principal" title="' + escHtml(deviation.principal) + '">' + badge + members + '</td>';
|
||||
} else {
|
||||
const principalShort = shortPrincipal(deviation.principal);
|
||||
principalCell = '<td class="col-principal"><span class="cell-truncate" title="' + escHtml(deviation.principal) + '">' + escHtml(principalShort) + '</span></td>';
|
||||
}
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td class="col-site"><span class="cell-truncate" title="' + escHtml(deviation.site_url) + '">' + escHtml(siteShort) + '</span></td>' +
|
||||
'<td class="col-object"><span class="cell-truncate" title="' + escHtml(deviation.object_url) + '">' + escHtml(objRel) + '</span></td>' +
|
||||
'<td class="col-type">' + escHtml(deviation.object_type) + '</td>' +
|
||||
principalCell +
|
||||
'<td class="col-role">' + escHtml(deviation.role_name) + '</td>' +
|
||||
'<td class="col-delta">' + escHtml(deviation.delta_type) + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('')
|
||||
: '<tr><td colspan="6">No permission deviations found for this job.</td></tr>';
|
||||
}
|
||||
|
||||
async function refreshSelectedJob() {
|
||||
if (!state.selectedJobId) return;
|
||||
const job = await requestJson('/api/scan-jobs/' + encodeURIComponent(state.selectedJobId));
|
||||
|
||||
els.selectedJobId.textContent = job.id;
|
||||
|
||||
if (job.scan_activity && job.status === 'running') {
|
||||
els.jobActivity.textContent = job.scan_activity;
|
||||
els.jobActivity.removeAttribute('hidden');
|
||||
} else {
|
||||
els.jobActivity.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
const tenantInfo = job.tenant_name ? ' | <strong>Tenant:</strong> ' + escHtml(job.tenant_name) : '';
|
||||
els.jobSummary.innerHTML =
|
||||
'<strong>Status:</strong> ' + job.status +
|
||||
tenantInfo +
|
||||
' | <strong>Targets:</strong> ' + job.total_targets +
|
||||
' | <strong>Processed:</strong> ' + job.processed_targets +
|
||||
' | <strong>Success:</strong> ' + job.successful_targets +
|
||||
' | <strong>Failed:</strong> ' + job.failed_targets +
|
||||
' | <strong>Items scanned:</strong> ' + job.items_scanned +
|
||||
(job.warning_message ? ' | <strong>Warning:</strong> ' + escHtml(job.warning_message) : '') +
|
||||
(job.error_message ? ' | <strong>Error:</strong> ' + escHtml(job.error_message) : '');
|
||||
|
||||
// Populate site filter dropdown (preserve selection if still valid)
|
||||
const prevSite = els.jobSiteFilter.value;
|
||||
const siteUrls = job.targets.map(function (t) { return t.site_url; });
|
||||
const uniqueSites = siteUrls.filter(function (v, i, a) { return a.indexOf(v) === i; }).sort();
|
||||
els.jobSiteFilter.innerHTML =
|
||||
'<option value="">All sites</option>' +
|
||||
uniqueSites.map(function (url) {
|
||||
return '<option value="' + escHtml(url) + '">' + escHtml(url) + '</option>';
|
||||
}).join('');
|
||||
if (prevSite && uniqueSites.indexOf(prevSite) !== -1) {
|
||||
els.jobSiteFilter.value = prevSite;
|
||||
}
|
||||
|
||||
// Store job data for re-render on filter change
|
||||
state.selectedJobData = job;
|
||||
|
||||
// Show export button
|
||||
els.exportJobBtn.removeAttribute('hidden');
|
||||
|
||||
// Build resolve sharing links section
|
||||
_renderResolveBlock(job);
|
||||
|
||||
renderJobTables(job);
|
||||
}
|
||||
|
||||
function _renderResolveBlock(job) {
|
||||
if (job.status === 'queued' || job.status === 'running') {
|
||||
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect unique link types present in this job's deviations
|
||||
var typeCounts = {};
|
||||
job.deviations.forEach(function (dev) {
|
||||
var lt = sharingLinkType(dev.principal);
|
||||
if (lt) {
|
||||
typeCounts[lt] = (typeCounts[lt] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
var types = Object.keys(typeCounts);
|
||||
if (!types.length) {
|
||||
els.sharingLinksResolveBlock.setAttribute('hidden', '');
|
||||
return;
|
||||
}
|
||||
|
||||
types.sort();
|
||||
els.sharingLinksTypes.innerHTML = types.map(function (lt) {
|
||||
var checked = SHARING_LINK_DEFAULT_CHECKED.indexOf(lt) !== -1 ? 'checked' : '';
|
||||
var riskCls = sharingLinkRiskClass(lt);
|
||||
return (
|
||||
'<label class="checkline">' +
|
||||
'<input type="checkbox" class="sharing-link-type-check" value="' + escHtml(lt) + '" ' + checked + '>' +
|
||||
'<span><span class="risk ' + riskCls + '">' + escHtml(lt) + '</span>' +
|
||||
' <span style="color:var(--cv-text-secondary);font-size:0.82rem">(' + typeCounts[lt] + ')</span></span>' +
|
||||
'</label>'
|
||||
);
|
||||
}).join('');
|
||||
|
||||
els.sharingLinksResolveBlock.removeAttribute('hidden');
|
||||
showFeedback(els.resolveFeedback, '', '');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto refresh
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
await refreshJobs();
|
||||
await refreshSelectedJob();
|
||||
} catch (err) {
|
||||
showFeedback(els.submitFeedback, 'Refresh error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (state.refreshTimer) {
|
||||
window.clearInterval(state.refreshTimer);
|
||||
}
|
||||
state.refreshTimer = window.setInterval(function () {
|
||||
tick().catch(function () {
|
||||
showFeedback(els.submitFeedback, 'Auto refresh failed.', 'error');
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// XSS helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Link types that are resolved by default (checked in the UI)
|
||||
var SHARING_LINK_DEFAULT_CHECKED = ['AnonymousEdit', 'AnonymousView', 'Flexible'];
|
||||
|
||||
function sharingLinkType(principal) {
|
||||
if (!principal || !principal.startsWith('SharingLinks.')) return null;
|
||||
var parts = principal.split('.');
|
||||
return parts.length >= 3 ? parts[2] : null;
|
||||
}
|
||||
|
||||
function sharingLinkRiskClass(linkType) {
|
||||
if (!linkType) return null;
|
||||
if (linkType.startsWith('Anonymous')) return 'critical';
|
||||
if (linkType === 'Flexible') return 'high';
|
||||
if (linkType.startsWith('Organization')) return 'info';
|
||||
if (linkType.startsWith('Direct')) return 'ok';
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
function relPath(siteUrl, objectUrl) {
|
||||
if (!objectUrl) return '-';
|
||||
const base = (siteUrl || '').replace(/\/$/, '');
|
||||
if (base && objectUrl.startsWith(base)) {
|
||||
const rel = objectUrl.slice(base.length) || '/';
|
||||
return rel.startsWith('/') ? rel : '/' + rel;
|
||||
}
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
function shortPrincipal(principal) {
|
||||
if (!principal) return '-';
|
||||
const idx = principal.lastIndexOf('|');
|
||||
return idx !== -1 ? principal.slice(idx + 1) : principal;
|
||||
}
|
||||
|
||||
function shortSite(siteUrl) {
|
||||
if (!siteUrl) return '-';
|
||||
const clean = siteUrl.replace(/\/$/, '');
|
||||
const idx = clean.lastIndexOf('/');
|
||||
return idx !== -1 ? clean.slice(idx + 1) : clean;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event wiring
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
els.downloadCertBtn.addEventListener('click', function () {
|
||||
const pem = els.certPem.value;
|
||||
if (!pem) return;
|
||||
const filename = els.downloadCertBtn.getAttribute('data-filename') || 'clearview.cer';
|
||||
const blob = new Blob([pem], { type: 'application/x-pem-file' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
els.copyCertBtn.addEventListener('click', function () {
|
||||
navigator.clipboard.writeText(els.certPem.value).then(function () {
|
||||
els.copyCertBtn.textContent = 'Copied!';
|
||||
setTimeout(function () { els.copyCertBtn.textContent = 'Copy to clipboard'; }, 2000);
|
||||
});
|
||||
});
|
||||
els.closeCertBtn.addEventListener('click', function () {
|
||||
els.certBlock.setAttribute('hidden', '');
|
||||
els.certPem.value = '';
|
||||
});
|
||||
|
||||
els.addTenantBtn.addEventListener('click', openTenantForm);
|
||||
els.cancelTenantBtn.addEventListener('click', closeTenantForm);
|
||||
els.saveTenantBtn.addEventListener('click', function () {
|
||||
saveTenant().catch(function (err) {
|
||||
showFeedback(els.tenantFeedback, 'Unexpected error: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
if (els.onboardingForm) {
|
||||
els.onboardingForm.addEventListener('submit', createScanAppAutomatically);
|
||||
}
|
||||
if (els.connectMicrosoftBtn) {
|
||||
els.connectMicrosoftBtn.addEventListener('click', function () {
|
||||
startMicrosoftConnect().catch(function (err) {
|
||||
showFeedback(els.tenantFeedback, 'Connect flow failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
els.scanTenantSelect.addEventListener('change', onScanTenantChange);
|
||||
els.manualScanForm.addEventListener('submit', createManualJob);
|
||||
els.csvScanForm.addEventListener('submit', createCsvJob);
|
||||
|
||||
els.refreshJobsBtn.addEventListener('click', function () {
|
||||
tick().catch(function () {
|
||||
showFeedback(els.submitFeedback, 'Refresh failed.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
els.jobTenantFilter.addEventListener('change', function () {
|
||||
tick().catch(function () {
|
||||
showFeedback(els.submitFeedback, 'Filter failed.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
els.jobSiteFilter.addEventListener('change', function () {
|
||||
if (state.selectedJobData) {
|
||||
renderJobTables(state.selectedJobData);
|
||||
}
|
||||
});
|
||||
|
||||
els.resolveSharingLinksBtn.addEventListener('click', function () {
|
||||
if (!state.selectedJobId) return;
|
||||
var checked = Array.from(
|
||||
els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check:checked')
|
||||
).map(function (cb) { return cb.value; });
|
||||
if (!checked.length) {
|
||||
showFeedback(els.resolveFeedback, 'Select at least one link type.', 'error');
|
||||
return;
|
||||
}
|
||||
els.resolveSharingLinksBtn.disabled = true;
|
||||
showFeedback(els.resolveFeedback, 'Resolving…', '');
|
||||
requestJson('/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + '/resolve-sharing-links', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ link_types: checked }),
|
||||
}).then(function (result) {
|
||||
showFeedback(
|
||||
els.resolveFeedback,
|
||||
result.resolved_groups + ' groups resolved, ' + result.updated_deviations + ' deviations updated.',
|
||||
'ok'
|
||||
);
|
||||
return refreshSelectedJob();
|
||||
}).catch(function (err) {
|
||||
showFeedback(els.resolveFeedback, 'Resolve failed: ' + err.message, 'error');
|
||||
}).finally(function () {
|
||||
els.resolveSharingLinksBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
els.exportJobBtn.addEventListener('click', function () {
|
||||
if (!state.selectedJobId) return;
|
||||
const siteFilter = els.jobSiteFilter.value;
|
||||
let url = '/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + '/export';
|
||||
if (siteFilter) {
|
||||
url += '?site_url=' + encodeURIComponent(siteFilter);
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
consumeOnboardingQueryState();
|
||||
initOnboardingSection().catch(function () {
|
||||
els.tenantSetupManual.removeAttribute('hidden');
|
||||
});
|
||||
loadTenants()
|
||||
.then(function () { return tick(); })
|
||||
.catch(function () {
|
||||
showFeedback(els.submitFeedback, 'Initial load failed.', 'error');
|
||||
});
|
||||
startAutoRefresh();
|
||||
})();
|
||||
74
containers/clearview/site/assets/clearview-concept.svg
Normal file
74
containers/clearview/site/assets/clearview-concept.svg
Normal file
@ -0,0 +1,74 @@
|
||||
<svg width="100%" viewBox="0 0 680 420" role="img" xmlns="http://www.w3.org/2000/svg">
|
||||
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Clearview logo and favicon</title>
|
||||
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Logo and favicon designs for Clearview, a SharePoint permissions web application</desc>
|
||||
<defs>
|
||||
<clipPath id="eyeClip">
|
||||
<ellipse cx="60" cy="60" rx="52" ry="32"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
|
||||
|
||||
<!-- Left section: Full logo -->
|
||||
<rect x="40" y="40" width="370" height="150" rx="12" style="fill:rgb(245, 244, 237);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="210" y="218" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Full logo</text>
|
||||
|
||||
<!-- Icon: eye with keyhole -->
|
||||
<!-- Eye outline -->
|
||||
<g transform="translate(68, 115)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
|
||||
<!-- Eye white shape -->
|
||||
<ellipse cx="52" cy="0" rx="52" ry="32" fill="#0ea5e9" opacity="0.15" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.15;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<ellipse cx="52" cy="0" rx="52" ry="32" fill="none" stroke="#0ea5e9" stroke-width="2.5" style="fill:none;stroke:rgb(14, 165, 233);color:rgb(0, 0, 0);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<!-- Iris -->
|
||||
<circle cx="52" cy="0" r="18" fill="#0ea5e9" opacity="0.25" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.25;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="52" cy="0" r="18" fill="none" stroke="#0ea5e9" stroke-width="2" style="fill:none;stroke:rgb(14, 165, 233);color:rgb(0, 0, 0);stroke-width:2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<!-- Pupil / keyhole -->
|
||||
<circle cx="52" cy="-3" r="6" fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<rect x="49" y="0" width="6" height="10" rx="3" fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<!-- Eyelash top hint -->
|
||||
<path d="M10 -18 Q52 -48 94 -18" fill="none" stroke="#0ea5e9" stroke-width="2" opacity="0.4" style="fill:none;stroke:rgb(14, 165, 233);color:rgb(0, 0, 0);stroke-width:2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.4;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<text x="216" y="128" style="fill:rgb(20, 20, 19);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:38px;font-weight:500;text-anchor:start;dominant-baseline:auto">
|
||||
<tspan fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:38px;font-weight:500;text-anchor:start;dominant-baseline:auto">Clear</tspan><tspan fill="var(--color-text-primary)" style="fill:rgb(20, 20, 19);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:38px;font-weight:500;text-anchor:start;dominant-baseline:auto">view</tspan>
|
||||
</text>
|
||||
<text x="216" y="150" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:13px;font-weight:400;text-anchor:start;dominant-baseline:auto">permissions & access insights</text>
|
||||
|
||||
<!-- Right section: Icon variants -->
|
||||
<rect x="430" y="40" width="210" height="330" rx="12" style="fill:rgb(245, 244, 237);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="535" y="395" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Icon variants</text>
|
||||
|
||||
<!-- Large icon -->
|
||||
<g transform="translate(478, 130)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
|
||||
<rect x="0" y="0" width="114" height="114" rx="20" fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<!-- Eye on blue bg -->
|
||||
<ellipse cx="57" cy="57" rx="46" ry="28" fill="white" opacity="0.2" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.2;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<ellipse cx="57" cy="57" rx="46" ry="28" fill="none" stroke="white" stroke-width="2.5" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(0, 0, 0);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="57" cy="57" r="16" fill="white" opacity="0.25" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.25;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="57" cy="57" r="16" fill="none" stroke="white" stroke-width="2" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(0, 0, 0);stroke-width:2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="57" cy="54" r="6" fill="white" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<rect x="54" y="57" width="6" height="9" rx="3" fill="white" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</g>
|
||||
<text x="535" y="266" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">App icon (512px)</text>
|
||||
|
||||
<!-- Small favicon -->
|
||||
<g transform="translate(503, 290)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
|
||||
<rect x="0" y="0" width="64" height="64" rx="10" fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<ellipse cx="32" cy="32" rx="26" ry="16" fill="none" stroke="white" stroke-width="2" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(0, 0, 0);stroke-width:2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="32" cy="29" r="6" fill="white" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<rect x="29" y="32" width="6" height="8" rx="3" fill="white" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</g>
|
||||
<text x="535" y="372" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Favicon (64px)</text>
|
||||
|
||||
<!-- Dark variant label -->
|
||||
<text x="210" y="252" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Color palette</text>
|
||||
<rect x="90" y="260" width="28" height="18" rx="4" fill="#0ea5e9" style="fill:rgb(14, 165, 233);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="90" y="294" style="text-anchor:start;font-size:11px;fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:11px;font-weight:400;text-anchor:start;dominant-baseline:auto">#0ea5e9</text>
|
||||
<rect x="130" y="260" width="28" height="18" rx="4" fill="#0369a1" style="fill:rgb(3, 105, 161);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="130" y="294" style="text-anchor:start;font-size:11px;fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:11px;font-weight:400;text-anchor:start;dominant-baseline:auto">#0369a1</text>
|
||||
<rect x="170" y="260" width="28" height="18" rx="4" fill="var(--color-text-primary)" style="fill:rgb(20, 20, 19);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="170" y="294" style="text-anchor:start;font-size:11px;fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:11px;font-weight:400;text-anchor:start;dominant-baseline:auto">text</text>
|
||||
<rect x="210" y="260" width="28" height="18" rx="4" fill="var(--color-text-secondary)" style="fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<text x="210" y="294" style="text-anchor:start;font-size:11px;fill:rgb(61, 61, 58);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Anthropic Sans, sans-serif;font-size:11px;font-weight:400;text-anchor:start;dominant-baseline:auto">muted</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
16
containers/clearview/site/assets/clearview-logo.svg
Normal file
16
containers/clearview/site/assets/clearview-logo.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg width="286" height="72" viewBox="0 0 286 72" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="logoTitle logoDesc">
|
||||
<title id="logoTitle">Clearview</title>
|
||||
<desc id="logoDesc">Clearview logo with eye and keyhole icon</desc>
|
||||
<g transform="translate(0 2)">
|
||||
<ellipse cx="34" cy="34" rx="34" ry="20" fill="#0EA5E9" fill-opacity="0.16"/>
|
||||
<ellipse cx="34" cy="34" rx="34" ry="20" stroke="#0EA5E9" stroke-width="2.4"/>
|
||||
<circle cx="34" cy="34" r="12" fill="#0EA5E9" fill-opacity="0.24"/>
|
||||
<circle cx="34" cy="34" r="12" stroke="#0EA5E9" stroke-width="2"/>
|
||||
<circle cx="34" cy="31" r="4" fill="#0EA5E9"/>
|
||||
<rect x="32" y="34" width="4" height="8" rx="2" fill="#0EA5E9"/>
|
||||
<path d="M8 22C16 14 25 10 34 10C43 10 52 14 60 22" stroke="#0EA5E9" stroke-opacity="0.42" stroke-width="2"/>
|
||||
</g>
|
||||
<text x="80" y="44" font-size="36" font-weight="600" font-family="'Space Grotesk', 'Avenir Next', 'Segoe UI', sans-serif">
|
||||
<tspan fill="#0EA5E9">Clear</tspan><tspan fill="#141413">view</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
6
containers/clearview/site/assets/favicon.svg
Normal file
6
containers/clearview/site/assets/favicon.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Clearview">
|
||||
<rect width="32" height="32" rx="6" fill="#0ea5e9"/>
|
||||
<ellipse cx="16" cy="16" rx="13" ry="8" fill="none" stroke="white" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="14.5" r="3" fill="white"/>
|
||||
<rect x="14.5" y="16" width="3" height="4" rx="1.5" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
337
containers/clearview/site/index.html
Normal file
337
containers/clearview/site/index.html
Normal file
@ -0,0 +1,337 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Clearview | SharePoint Permission Deviations</title>
|
||||
<meta name="description" content="Clearview scans SharePoint sites and reports only permission deviations from root level.">
|
||||
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-orb orb-one" aria-hidden="true"></div>
|
||||
<div class="bg-orb orb-two" aria-hidden="true"></div>
|
||||
|
||||
<header class="topbar slide-in">
|
||||
<a href="#" class="brand" aria-label="Clearview home">
|
||||
<img src="assets/clearview-logo.svg" alt="Clearview logo" class="brand-logo">
|
||||
</a>
|
||||
<div class="topbar-actions">
|
||||
<button id="refreshJobsBtn" class="btn btn-outline" type="button">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="hero fade-up" style="--delay: 0.05s">
|
||||
<p class="eyebrow">Root Permission Drift Detection</p>
|
||||
<h1>Monitor SharePoint permissions across all your customers</h1>
|
||||
<p class="lede">
|
||||
Clearview scans down to folder and file level and reports only rights that deviate from the
|
||||
root permissions of each site.
|
||||
</p>
|
||||
<div class="hero-stats" id="heroStats">
|
||||
<article>
|
||||
<span class="kpi" id="statTenants">0</span>
|
||||
<span class="label">Tenants</span>
|
||||
</article>
|
||||
<article>
|
||||
<span class="kpi" id="statJobs">0</span>
|
||||
<span class="label">Jobs</span>
|
||||
</article>
|
||||
<article>
|
||||
<span class="kpi" id="statRunning">0</span>
|
||||
<span class="label">Active Jobs</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Tenants panel -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<section class="panel fade-up" style="--delay: 0.11s">
|
||||
<div class="panel-header split">
|
||||
<h2>Tenants</h2>
|
||||
<button id="addTenantBtn" class="btn btn-outline" type="button">Add Tenant</button>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit tenant form (hidden by default) -->
|
||||
<div id="addTenantForm" class="scan-form" hidden>
|
||||
<h3>New Tenant</h3>
|
||||
|
||||
<!-- Automated onboarding -->
|
||||
<div id="tenantSetupAutomated" class="setup-note" hidden>
|
||||
<h3>Azure App Setup (automated)</h3>
|
||||
<p>Connect to the customer's Microsoft tenant, then create a dedicated scan app automatically.</p>
|
||||
<ul>
|
||||
<li>Click <strong>Connect Microsoft</strong> and approve admin consent for the customer tenant</li>
|
||||
<li>Created scan app receives SharePoint application permission: <code>Sites.FullControl.All</code></li>
|
||||
</ul>
|
||||
<form id="onboardingForm" class="onboarding-form" action="#" method="post">
|
||||
<div class="onboarding-grid">
|
||||
<div class="onboarding-wide">
|
||||
<button id="connectMicrosoftBtn" class="btn btn-outline" type="button">Connect Microsoft</button>
|
||||
</div>
|
||||
<label class="onboarding-wide">
|
||||
Connected Tenant ID
|
||||
<input id="connectedTenantId" type="text" placeholder="Connect first to populate tenant id">
|
||||
</label>
|
||||
<label class="onboarding-wide">
|
||||
New Scan App Display Name
|
||||
<input id="scanAppDisplayName" type="text" value="Clearview Scan App">
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-outline" type="submit">Create Scan App Automatically</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Manual onboarding -->
|
||||
<div id="tenantSetupManual" class="setup-note" hidden>
|
||||
<h3>Azure App Setup (manual)</h3>
|
||||
<p>Create a dedicated Azure app registration in the customer's tenant and grant it SharePoint access.</p>
|
||||
<ol class="setup-steps">
|
||||
<li>Open <strong>Azure Portal</strong> and go to <strong>Entra ID → App registrations → New registration</strong>.</li>
|
||||
<li>Fill in a name (e.g. <em>Clearview Scan App</em>), select <strong>Single tenant</strong>, click <strong>Register</strong>.</li>
|
||||
<li>Copy the <strong>Directory (tenant) ID</strong> and <strong>Application (client) ID</strong> from the Overview page.</li>
|
||||
<li>Go to <strong>API permissions → Add → SharePoint → Application permissions</strong>, add <code>Sites.FullControl.All</code>.</li>
|
||||
<li>Click <strong>Grant admin consent</strong>.</li>
|
||||
<li>Go to <strong>Certificates & secrets → New client secret</strong>, copy the <strong>Value</strong> immediately.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Tenant fields -->
|
||||
<div class="auth-grid">
|
||||
<label class="onboarding-wide">
|
||||
Tenant Name (label for your reference)
|
||||
<input id="newTenantName" type="text" placeholder="Contoso">
|
||||
</label>
|
||||
<label>
|
||||
Tenant ID
|
||||
<input id="newTenantTenantId" type="text" placeholder="00000000-0000-0000-0000-000000000000">
|
||||
</label>
|
||||
<label>
|
||||
Client ID
|
||||
<input id="newTenantClientId" type="text" placeholder="00000000-0000-0000-0000-000000000000">
|
||||
</label>
|
||||
<label class="auth-secret">
|
||||
Client Secret <span style="font-weight:400;font-size:0.82rem">(optional — not needed when using a certificate)</span>
|
||||
<input id="newTenantClientSecret" type="password" placeholder="Leave empty if you will generate a certificate">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="saveTenantBtn" class="btn btn-solid" type="button">Save Tenant</button>
|
||||
<button id="cancelTenantBtn" class="btn btn-outline" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tenants table -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tenant ID</th>
|
||||
<th>Client ID</th>
|
||||
<th>Auth</th>
|
||||
<th>Added</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tenantsTableBody">
|
||||
<tr><td colspan="6">No tenants configured yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="tenantFeedback" class="feedback" aria-live="polite"></div>
|
||||
|
||||
<!-- Certificate display block (shown after generation) -->
|
||||
<div id="certBlock" class="cert-block" hidden>
|
||||
<h3>Public Certificate</h3>
|
||||
<p>Upload this certificate in <strong>Azure Portal → App registrations → [your app] → Certificates & secrets → Certificates → Upload certificate</strong>.</p>
|
||||
<textarea id="certPem" class="cert-pem" rows="10" readonly></textarea>
|
||||
<div class="form-actions">
|
||||
<button id="downloadCertBtn" class="btn btn-solid" type="button">Download .cer</button>
|
||||
<button id="copyCertBtn" class="btn btn-outline" type="button">Copy to clipboard</button>
|
||||
<button id="closeCertBtn" class="btn btn-outline" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Start New Scan panel -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<section class="panel fade-up" style="--delay: 0.17s">
|
||||
<div class="panel-header split">
|
||||
<h2>Start New Scan</h2>
|
||||
<span class="badge">Async job queue</span>
|
||||
</div>
|
||||
|
||||
<!-- Tenant selector -->
|
||||
<div class="scan-form auth-block">
|
||||
<h3>Tenant</h3>
|
||||
<label>
|
||||
Select Tenant Profile
|
||||
<select id="scanTenantSelect">
|
||||
<option value="">-- Select a tenant --</option>
|
||||
<option value="__manual__">Manual credentials...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Manual credentials (only shown when __manual__ selected) -->
|
||||
<div id="manualCredentialsBlock" class="scan-form auth-block" hidden>
|
||||
<h3>Microsoft App Credentials</h3>
|
||||
<div class="auth-grid">
|
||||
<label>
|
||||
Tenant ID
|
||||
<input id="tenantId" type="text" placeholder="00000000-0000-0000-0000-000000000000">
|
||||
</label>
|
||||
<label>
|
||||
Client ID
|
||||
<input id="clientId" type="text" placeholder="00000000-0000-0000-0000-000000000000">
|
||||
</label>
|
||||
<label class="auth-secret">
|
||||
Client Secret
|
||||
<input id="clientSecret" type="password" placeholder="Client secret">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<form id="manualScanForm" class="scan-form" action="#" method="post">
|
||||
<h3>Manual URLs</h3>
|
||||
<label>
|
||||
Site URLs (one per line)
|
||||
<textarea id="manualUrls" rows="6" placeholder="https://contoso.sharepoint.com/sites/finance https://contoso.sharepoint.com/sites/hr"></textarea>
|
||||
</label>
|
||||
<label class="checkline">
|
||||
<input id="manualSkipDefaults" type="checkbox" checked>
|
||||
<span>Skip default sites (tenant root, app catalog)</span>
|
||||
</label>
|
||||
<button class="btn btn-solid" type="submit">Queue manual scan</button>
|
||||
</form>
|
||||
|
||||
<form id="csvScanForm" class="scan-form" action="#" method="post" enctype="multipart/form-data">
|
||||
<h3>CSV Import</h3>
|
||||
<label>
|
||||
Microsoft Sites export (CSV)
|
||||
<input id="csvFile" type="file" accept=".csv,text/csv">
|
||||
</label>
|
||||
<label class="checkline">
|
||||
<input id="csvSkipDefaults" type="checkbox" checked>
|
||||
<span>Skip default sites (tenant root, app catalog)</span>
|
||||
</label>
|
||||
<button class="btn btn-solid" type="submit">Queue CSV scan</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="submitFeedback" class="feedback" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Scan Jobs panel -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<section class="panel fade-up" style="--delay: 0.23s">
|
||||
<div class="panel-header split">
|
||||
<h2>Scan Jobs</h2>
|
||||
<div class="panel-header-right">
|
||||
<select id="jobTenantFilter" class="filter-select">
|
||||
<option value="">All tenants</option>
|
||||
</select>
|
||||
<span id="jobAutoRefresh" class="badge">Auto refresh: on</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Tenant</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Targets</th>
|
||||
<th>Items</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody">
|
||||
<tr><td colspan="8">No jobs yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Selected Job Details panel -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<section class="panel fade-up" style="--delay: 0.29s">
|
||||
<div class="panel-header split">
|
||||
<h2>Selected Job Details</h2>
|
||||
<div class="panel-header-right">
|
||||
<select id="jobSiteFilter" class="filter-select">
|
||||
<option value="">All sites</option>
|
||||
</select>
|
||||
<button id="exportJobBtn" class="btn btn-outline" type="button" hidden>Export Excel</button>
|
||||
<span id="selectedJobId" class="badge">No selection</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="jobSummary" class="job-summary">Select a job to inspect targets and deviations.</div>
|
||||
<div id="jobActivity" class="job-activity" hidden></div>
|
||||
|
||||
<h3 class="subheading">Targets</h3>
|
||||
<div class="table-wrap compact-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>Attempts</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="targetsTableBody">
|
||||
<tr><td colspan="4">No job selected.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="sharingLinksResolveBlock" hidden>
|
||||
<h3 class="subheading">Resolve Sharing Links</h3>
|
||||
<p class="resolve-hint">Fetch the actual recipients for the selected link types. Anonymous links have no resolvable members.</p>
|
||||
<div id="sharingLinksTypes" class="sharing-link-types"></div>
|
||||
<div class="form-actions" style="margin-top:0.6rem">
|
||||
<button id="resolveSharingLinksBtn" class="btn btn-outline" type="button">Resolve</button>
|
||||
</div>
|
||||
<div id="resolveFeedback" class="feedback" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="subheading">Permission Deviations</h3>
|
||||
<div class="table-wrap deviations-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Object</th>
|
||||
<th>Type</th>
|
||||
<th>Principal</th>
|
||||
<th>Role</th>
|
||||
<th>Delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deviationsTableBody">
|
||||
<tr><td colspan="6">No deviation data yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
644
containers/clearview/site/styles.css
Normal file
644
containers/clearview/site/styles.css
Normal file
@ -0,0 +1,644 @@
|
||||
:root {
|
||||
--cv-accent: #0ea5e9;
|
||||
--cv-accent-dark: #0369a1;
|
||||
--cv-text-primary: #141413;
|
||||
--cv-text-secondary: #3d3d3a;
|
||||
--cv-surface: #f5f4ed;
|
||||
--cv-page: #f9faf8;
|
||||
--cv-white: #ffffff;
|
||||
--cv-border: rgba(20, 20, 19, 0.12);
|
||||
--cv-shadow: 0 20px 44px rgba(3, 105, 161, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--cv-text-primary);
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(14, 165, 233, 0.16), transparent 42%),
|
||||
radial-gradient(circle at 86% 14%, rgba(3, 105, 161, 0.12), transparent 36%),
|
||||
linear-gradient(165deg, var(--cv-page), #eef7fb 55%, #f6f4ee 100%);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-orb {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(2px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.orb-one {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
top: -160px;
|
||||
left: -120px;
|
||||
background: radial-gradient(circle at center, rgba(14, 165, 233, 0.22), rgba(14, 165, 233, 0));
|
||||
}
|
||||
|
||||
.orb-two {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
right: -100px;
|
||||
bottom: -120px;
|
||||
background: radial-gradient(circle at center, rgba(3, 105, 161, 0.2), rgba(3, 105, 161, 0));
|
||||
}
|
||||
|
||||
.topbar {
|
||||
width: min(1100px, calc(100% - 2rem));
|
||||
margin: 1.1rem auto 0;
|
||||
padding: 0.95rem 1.1rem;
|
||||
border: 1px solid var(--cv-border);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 10px 24px rgba(20, 20, 19, 0.08);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1100px, calc(100% - 2rem));
|
||||
margin: 1rem auto 2.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--cv-border);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.78));
|
||||
box-shadow: var(--cv-shadow);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--cv-accent-dark);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin-top: 0.55rem;
|
||||
font-size: clamp(1.65rem, 3.8vw, 2.7rem);
|
||||
max-width: 20ch;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--cv-text-secondary);
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
margin-top: 1.3rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
background: var(--cv-surface);
|
||||
border: 1px solid rgba(14, 165, 233, 0.26);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
font-size: 1.5rem;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-weight: 700;
|
||||
color: var(--cv-accent-dark);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--cv-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.panel-header.split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.scan-form {
|
||||
background: rgba(245, 244, 237, 0.55);
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.scan-form h3 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.setup-note {
|
||||
margin-bottom: 0.9rem;
|
||||
background: rgba(16, 33, 44, 0.04);
|
||||
border: 1px solid rgba(3, 105, 161, 0.28);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
color: var(--cv-text-secondary);
|
||||
}
|
||||
|
||||
.setup-note h3 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: 0.98rem;
|
||||
color: var(--cv-text-primary);
|
||||
}
|
||||
|
||||
.setup-note p {
|
||||
margin: 0 0 0.45rem;
|
||||
}
|
||||
|
||||
.setup-note ul {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
|
||||
.setup-steps {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 1.3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.setup-steps li {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.onboarding-form {
|
||||
margin-top: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.onboarding-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.onboarding-wide {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.auth-block {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.auth-secret {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.checkline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--cv-text-secondary);
|
||||
}
|
||||
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--cv-border);
|
||||
background: var(--cv-white);
|
||||
font: inherit;
|
||||
color: var(--cv-text-primary);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 7rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
button:focus {
|
||||
outline: 2px solid rgba(14, 165, 233, 0.38);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-weight: 600;
|
||||
padding: 0.66rem 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-solid {
|
||||
background: linear-gradient(135deg, var(--cv-accent), var(--cv-accent-dark));
|
||||
color: var(--cv-white);
|
||||
box-shadow: 0 8px 20px rgba(3, 105, 161, 0.32);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
color: var(--cv-accent-dark);
|
||||
border: 1px solid rgba(3, 105, 161, 0.4);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.38rem 0.62rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: var(--cv-accent-dark);
|
||||
border: 1px solid rgba(3, 105, 161, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.62rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 0.9rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid rgba(20, 20, 19, 0.14);
|
||||
color: var(--cv-text-secondary);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
min-height: 2.2rem;
|
||||
}
|
||||
|
||||
.feedback.ok {
|
||||
border-color: rgba(3, 105, 161, 0.35);
|
||||
color: #064566;
|
||||
}
|
||||
|
||||
.feedback.error {
|
||||
border-color: rgba(137, 20, 20, 0.35);
|
||||
color: #7a1212;
|
||||
}
|
||||
|
||||
.job-activity {
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--cv-accent-dark);
|
||||
font-family: "IBM Plex Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.job-summary {
|
||||
margin-bottom: 0.85rem;
|
||||
background: rgba(245, 244, 237, 0.6);
|
||||
border: 1px solid rgba(20, 20, 19, 0.13);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--cv-text-secondary);
|
||||
}
|
||||
|
||||
.panel-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.32rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(3, 105, 161, 0.3);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
color: var(--cv-accent-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.cert-block {
|
||||
margin-top: 0.9rem;
|
||||
background: rgba(16, 33, 44, 0.04);
|
||||
border: 1px solid rgba(3, 105, 161, 0.28);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cert-block h3 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.cert-block p {
|
||||
margin: 0;
|
||||
color: var(--cv-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cert-pem {
|
||||
font-family: "IBM Plex Mono", "Courier New", monospace;
|
||||
font-size: 0.78rem;
|
||||
resize: vertical;
|
||||
min-height: 8rem;
|
||||
background: var(--cv-white);
|
||||
}
|
||||
|
||||
.tenant-tag {
|
||||
display: inline-block;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: var(--cv-accent-dark);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.52rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
margin: 0.9rem 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 920px;
|
||||
}
|
||||
|
||||
.compact-wrap table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
/* Deviations table: fixed column widths that fit on 1080p */
|
||||
.deviations-wrap table {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deviations-wrap .col-site { width: 9%; }
|
||||
.deviations-wrap .col-object { width: 38%; }
|
||||
.deviations-wrap .col-type { width: 10%; }
|
||||
.deviations-wrap .col-principal{ width: 27%; }
|
||||
.deviations-wrap .col-role { width: 10%; }
|
||||
.deviations-wrap .col-delta { width: 6%; }
|
||||
|
||||
.cell-truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cell-members {
|
||||
font-size: 0.8rem;
|
||||
color: var(--cv-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sharing-link-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.resolve-hint {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--cv-text-secondary);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.8rem 0.68rem;
|
||||
border-bottom: 1px solid rgba(20, 20, 19, 0.1);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
color: var(--cv-accent-dark);
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--cv-text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--cv-text-primary);
|
||||
}
|
||||
|
||||
.risk {
|
||||
border-radius: 999px;
|
||||
padding: 0.24rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.risk.warn {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: var(--cv-accent-dark);
|
||||
}
|
||||
|
||||
.risk.high {
|
||||
background: rgba(234, 88, 12, 0.14);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.risk.ok,
|
||||
.risk.info,
|
||||
.risk.low {
|
||||
background: rgba(3, 105, 161, 0.1);
|
||||
color: #084d72;
|
||||
}
|
||||
|
||||
.risk.critical {
|
||||
background: #10212c;
|
||||
color: #d6f4ff;
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 540ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
animation: fadeUp 620ms ease-out forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 930px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-secret {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.onboarding-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.layout,
|
||||
.topbar {
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
1
containers/clearview/src/clearview_app/__init__.py
Normal file
1
containers/clearview/src/clearview_app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Clearview application package."""
|
||||
56
containers/clearview/src/clearview_app/cert.py
Normal file
56
containers/clearview/src/clearview_app/cert.py
Normal file
@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneratedCertificate:
|
||||
private_key_pem: str
|
||||
public_cert_pem: str
|
||||
thumbprint: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def generate_tenant_certificate(valid_years: int = 2) -> GeneratedCertificate:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Clearview Scan App"),
|
||||
])
|
||||
expires_at = datetime.utcnow() + timedelta(days=365 * valid_years)
|
||||
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.utcnow())
|
||||
.not_valid_after(expires_at)
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
public_cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
|
||||
# SHA-1 thumbprint — this is what Azure AD and MSAL expect
|
||||
thumbprint = hashlib.sha1(cert.public_bytes(serialization.Encoding.DER)).hexdigest().upper()
|
||||
|
||||
return GeneratedCertificate(
|
||||
private_key_pem=private_key_pem,
|
||||
public_cert_pem=public_cert_pem,
|
||||
thumbprint=thumbprint,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
38
containers/clearview/src/clearview_app/config.py
Normal file
38
containers/clearview/src/clearview_app/config.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://clearview:clearview@postgres:5432/clearview",
|
||||
)
|
||||
|
||||
SCAN_TARGET_TIMEOUT_SEC = _int_env("SCAN_TARGET_TIMEOUT_SEC", 3600)
|
||||
SCAN_TARGET_MAX_RETRIES = _int_env("SCAN_TARGET_MAX_RETRIES", 2)
|
||||
SCAN_RETRY_BASE_DELAY_SEC = _int_env("SCAN_RETRY_BASE_DELAY_SEC", 2)
|
||||
SCAN_JOB_POLL_INTERVAL_SEC = _int_env("SCAN_JOB_POLL_INTERVAL_SEC", 3)
|
||||
|
||||
# Placeholder mode until Graph/SharePoint auth integration is implemented.
|
||||
SHAREPOINT_SCAN_MODE = os.getenv("SHAREPOINT_SCAN_MODE", "sharepoint_app_only")
|
||||
|
||||
ONBOARDING_CLIENT_ID = os.getenv("ONBOARDING_CLIENT_ID", "")
|
||||
ONBOARDING_CLIENT_SECRET = os.getenv("ONBOARDING_CLIENT_SECRET", "")
|
||||
ONBOARDING_REDIRECT_URI = os.getenv("ONBOARDING_REDIRECT_URI", "")
|
||||
|
||||
SCAN_HTTP_TIMEOUT_SEC = _int_env("SCAN_HTTP_TIMEOUT_SEC", 30)
|
||||
SCAN_HTTP_MAX_RETRIES = _int_env("SCAN_HTTP_MAX_RETRIES", 3)
|
||||
SCAN_HTTP_BACKOFF_SEC = _int_env("SCAN_HTTP_BACKOFF_SEC", 2)
|
||||
|
||||
SCAN_LIST_PAGE_SIZE = _int_env("SCAN_LIST_PAGE_SIZE", 200)
|
||||
SCAN_MAX_ITEMS_PER_LIST = _int_env("SCAN_MAX_ITEMS_PER_LIST", 10000)
|
||||
57
containers/clearview/src/clearview_app/csv_import.py
Normal file
57
containers/clearview/src/clearview_app/csv_import.py
Normal file
@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from .default_sites import normalize_site_url
|
||||
|
||||
|
||||
class CsvImportResult:
|
||||
def __init__(self) -> None:
|
||||
self.urls: list[str] = []
|
||||
self.invalid_rows: list[str] = []
|
||||
self.total_rows: int = 0
|
||||
|
||||
|
||||
def parse_sites_csv(content: bytes) -> CsvImportResult:
|
||||
result = CsvImportResult()
|
||||
|
||||
text = content.decode("utf-8-sig", errors="replace")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
|
||||
if not reader.fieldnames:
|
||||
return result
|
||||
|
||||
url_key = _resolve_url_column(reader.fieldnames)
|
||||
if not url_key:
|
||||
return result
|
||||
|
||||
seen: set[str] = set()
|
||||
|
||||
for idx, row in enumerate(reader, start=2):
|
||||
result.total_rows += 1
|
||||
raw_url = (row.get(url_key) or "").strip()
|
||||
if not raw_url:
|
||||
result.invalid_rows.append(f"row {idx}: empty URL")
|
||||
continue
|
||||
|
||||
normalized = normalize_site_url(raw_url)
|
||||
if not normalized:
|
||||
result.invalid_rows.append(f"row {idx}: invalid URL '{raw_url}'")
|
||||
continue
|
||||
|
||||
if normalized in seen:
|
||||
continue
|
||||
|
||||
seen.add(normalized)
|
||||
result.urls.append(normalized)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_url_column(fieldnames: list[str]) -> str | None:
|
||||
mapping = {name.strip().lower(): name for name in fieldnames}
|
||||
for candidate in ("url", "site url", "siteurl"):
|
||||
if candidate in mapping:
|
||||
return mapping[candidate]
|
||||
return None
|
||||
15
containers/clearview/src/clearview_app/db.py
Normal file
15
containers/clearview/src/clearview_app/db.py
Normal file
@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from .config import DATABASE_URL
|
||||
|
||||
def _normalize_database_url(url: str) -> str:
|
||||
if url.startswith("postgresql://"):
|
||||
return "postgresql+psycopg://" + url[len("postgresql://") :]
|
||||
return url
|
||||
|
||||
|
||||
engine = create_engine(_normalize_database_url(DATABASE_URL), pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
34
containers/clearview/src/clearview_app/default_sites.py
Normal file
34
containers/clearview/src/clearview_app/default_sites.py
Normal file
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def normalize_site_url(raw_url: str) -> str:
|
||||
url = raw_url.strip()
|
||||
if not url:
|
||||
return ""
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return ""
|
||||
|
||||
path = parsed.path.rstrip("/")
|
||||
clean = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}"
|
||||
return clean
|
||||
|
||||
|
||||
def is_default_site(site_url: str, template_name: str | None = None) -> bool:
|
||||
parsed = urlparse(site_url)
|
||||
path = (parsed.path or "").strip().lower().rstrip("/")
|
||||
template = (template_name or "").strip().lower()
|
||||
|
||||
if path in ("", "/"):
|
||||
return True
|
||||
|
||||
if path.startswith("/sites/appcatalog") or path.startswith("/teams/appcatalog"):
|
||||
return True
|
||||
|
||||
if "app catalog site" in template:
|
||||
return True
|
||||
|
||||
return False
|
||||
728
containers/clearview/src/clearview_app/main.py
Normal file
728
containers/clearview/src/clearview_app/main.py
Normal file
@ -0,0 +1,728 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import io
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from .csv_import import parse_sites_csv
|
||||
from .db import SessionLocal, engine
|
||||
from .default_sites import is_default_site, normalize_site_url
|
||||
from .models import Base, PermissionDeviation, ScanJob, ScanTarget, TenantProfile
|
||||
from .onboarding import OnboardingError, consume_callback_state, create_connect_url, create_scan_app_for_tenant
|
||||
from .cert import generate_tenant_certificate
|
||||
from .schemas import (
|
||||
ConnectMicrosoftResponse,
|
||||
CreateScanAppRequest,
|
||||
CreateScanAppResponse,
|
||||
CreateScanJobRequest,
|
||||
CreateTenantProfileRequest,
|
||||
PermissionDeviationItem,
|
||||
ResolveSharingLinksRequest,
|
||||
ResolveSharingLinksResponse,
|
||||
ScanJobCreateResponse,
|
||||
ScanJobDetail,
|
||||
ScanJobSummary,
|
||||
ScanTargetItem,
|
||||
TenantCertificateResponse,
|
||||
TenantProfileItem,
|
||||
)
|
||||
from .scanner import AuthConfig
|
||||
from .worker import ScanWorker
|
||||
|
||||
app = FastAPI(title="Clearview API", version="0.1.0")
|
||||
worker = ScanWorker()
|
||||
|
||||
SITE_DIR = Path(__file__).resolve().parents[2] / "site"
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_schema_columns()
|
||||
worker.start()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def on_shutdown() -> None:
|
||||
worker.stop()
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/tenants", response_model=list[TenantProfileItem])
|
||||
def list_tenants() -> list[TenantProfileItem]:
|
||||
with SessionLocal() as db:
|
||||
profiles = list(
|
||||
db.execute(select(TenantProfile).order_by(TenantProfile.created_at.asc())).scalars()
|
||||
)
|
||||
return [_to_tenant_item(p) for p in profiles]
|
||||
|
||||
|
||||
@app.post("/api/tenants", response_model=TenantProfileItem, status_code=201)
|
||||
def create_tenant(payload: CreateTenantProfileRequest) -> TenantProfileItem:
|
||||
with SessionLocal() as db:
|
||||
now = datetime.utcnow()
|
||||
profile = TenantProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=payload.name.strip(),
|
||||
tenant_id=payload.tenant_id.strip(),
|
||||
client_id=payload.client_id.strip(),
|
||||
client_secret=payload.client_secret.strip() if payload.client_secret else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
return _to_tenant_item(profile)
|
||||
|
||||
|
||||
@app.post("/api/tenants/{profile_id}/generate-certificate", response_model=TenantCertificateResponse)
|
||||
def generate_certificate(profile_id: str) -> TenantCertificateResponse:
|
||||
with SessionLocal() as db:
|
||||
profile = db.get(TenantProfile, profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Tenant profile not found")
|
||||
result = generate_tenant_certificate()
|
||||
profile.cert_private_key = result.private_key_pem
|
||||
profile.cert_thumbprint = result.thumbprint
|
||||
profile.cert_expires_at = result.expires_at
|
||||
profile.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return TenantCertificateResponse(
|
||||
thumbprint=result.thumbprint,
|
||||
expires_at=result.expires_at,
|
||||
public_cert_pem=result.public_cert_pem,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/tenants/{profile_id}", status_code=204, response_class=Response)
|
||||
def delete_tenant(profile_id: str) -> Response:
|
||||
with SessionLocal() as db:
|
||||
profile = db.get(TenantProfile, profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Tenant profile not found")
|
||||
# Detach jobs from this profile before deleting
|
||||
db.execute(
|
||||
text("UPDATE scan_jobs SET tenant_profile_id = NULL WHERE tenant_profile_id = :pid"),
|
||||
{"pid": profile_id},
|
||||
)
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scan jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/scan-jobs", response_model=ScanJobCreateResponse)
|
||||
def create_scan_job(payload: CreateScanJobRequest) -> ScanJobCreateResponse:
|
||||
with SessionLocal() as db:
|
||||
tenant_id, client_id, client_secret, profile_id = _resolve_credentials(
|
||||
db=db,
|
||||
tenant_profile_id=payload.tenant_profile_id,
|
||||
tenant_id=payload.tenant_id,
|
||||
client_id=payload.client_id,
|
||||
client_secret=payload.client_secret,
|
||||
)
|
||||
raw_urls = [str(item) for item in payload.site_urls]
|
||||
return _create_job_from_urls(
|
||||
raw_urls=raw_urls,
|
||||
skip_default_sites=payload.skip_default_sites,
|
||||
source_type="manual",
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_profile_id=profile_id,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/scan-jobs/import-csv", response_model=ScanJobCreateResponse)
|
||||
def create_scan_job_from_csv(
|
||||
skip_default_sites: bool = True,
|
||||
tenant_profile_id: str | None = Form(None),
|
||||
tenant_id: str | None = Form(None),
|
||||
client_id: str | None = Form(None),
|
||||
client_secret: str | None = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
) -> ScanJobCreateResponse:
|
||||
with SessionLocal() as db:
|
||||
resolved_tenant_id, resolved_client_id, resolved_client_secret, profile_id = _resolve_credentials(
|
||||
db=db,
|
||||
tenant_profile_id=tenant_profile_id,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
content = file.file.read()
|
||||
parsed = parse_sites_csv(content)
|
||||
response = _create_job_from_urls(
|
||||
raw_urls=parsed.urls,
|
||||
skip_default_sites=skip_default_sites,
|
||||
source_type="csv",
|
||||
tenant_id=resolved_tenant_id,
|
||||
client_id=resolved_client_id,
|
||||
client_secret=resolved_client_secret,
|
||||
tenant_profile_id=profile_id,
|
||||
)
|
||||
|
||||
if parsed.invalid_rows:
|
||||
csv_warning = f"CSV issues: {len(parsed.invalid_rows)}"
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, response.job.id)
|
||||
if job:
|
||||
if job.warning_message:
|
||||
job.warning_message = f"{job.warning_message} | {csv_warning}"
|
||||
else:
|
||||
job.warning_message = csv_warning
|
||||
job.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
response.job.warning_message = job.warning_message
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/scan-jobs/{job_id}/cancel", response_model=ScanJobSummary)
|
||||
def cancel_scan_job(job_id: str) -> ScanJobSummary:
|
||||
with SessionLocal() as db:
|
||||
stmt = select(ScanJob).options(joinedload(ScanJob.tenant_profile)).where(ScanJob.id == job_id)
|
||||
job = db.execute(stmt).unique().scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if job.status not in ("queued", "running"):
|
||||
raise HTTPException(status_code=409, detail="Job is not queued or running")
|
||||
now = datetime.utcnow()
|
||||
job.status = "cancelled"
|
||||
job.updated_at = now
|
||||
job.finished_at = now
|
||||
job.scan_activity = None
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
stmt = select(ScanJob).options(joinedload(ScanJob.tenant_profile)).where(ScanJob.id == job_id)
|
||||
job = db.execute(stmt).unique().scalar_one()
|
||||
return _to_job_summary(job)
|
||||
|
||||
|
||||
@app.delete("/api/scan-jobs/{job_id}", status_code=204, response_class=Response)
|
||||
def delete_scan_job(job_id: str) -> Response:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if job.status in ("queued", "running"):
|
||||
raise HTTPException(status_code=409, detail="Cannot delete a job that is queued or running")
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/api/scan-jobs", response_model=list[ScanJobSummary])
|
||||
def list_scan_jobs(limit: int = 20, tenant_profile_id: str | None = None) -> list[ScanJobSummary]:
|
||||
with SessionLocal() as db:
|
||||
stmt = (
|
||||
select(ScanJob)
|
||||
.options(joinedload(ScanJob.tenant_profile))
|
||||
.order_by(ScanJob.created_at.desc())
|
||||
.limit(max(1, min(limit, 100)))
|
||||
)
|
||||
if tenant_profile_id:
|
||||
stmt = stmt.where(ScanJob.tenant_profile_id == tenant_profile_id)
|
||||
jobs = list(db.execute(stmt).unique().scalars())
|
||||
return [_to_job_summary(job) for job in jobs]
|
||||
|
||||
|
||||
@app.post("/api/scan-jobs/{job_id}/resolve-sharing-links", response_model=ResolveSharingLinksResponse)
|
||||
def resolve_sharing_links_endpoint(job_id: str, payload: ResolveSharingLinksRequest) -> ResolveSharingLinksResponse:
|
||||
from .scanner import resolve_sharing_link_members
|
||||
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if job.status in ("queued", "running"):
|
||||
raise HTTPException(status_code=409, detail="Job is still running")
|
||||
|
||||
cert_private_key: str | None = None
|
||||
cert_thumbprint: str | None = None
|
||||
if job.tenant_profile_id:
|
||||
profile = db.get(TenantProfile, job.tenant_profile_id)
|
||||
if profile:
|
||||
cert_private_key = profile.cert_private_key
|
||||
cert_thumbprint = profile.cert_thumbprint
|
||||
|
||||
auth = AuthConfig(
|
||||
tenant_id=job.auth_tenant_id or "",
|
||||
client_id=job.auth_client_id or "",
|
||||
client_secret=job.auth_client_secret or "",
|
||||
cert_private_key=cert_private_key,
|
||||
cert_thumbprint=cert_thumbprint,
|
||||
)
|
||||
|
||||
all_deviations = list(
|
||||
db.execute(select(PermissionDeviation).where(PermissionDeviation.job_id == job_id)).scalars()
|
||||
)
|
||||
|
||||
# Group by (site_url, principal) so each unique group is resolved once
|
||||
groups: dict[tuple[str, str], list[int]] = {}
|
||||
for dev in all_deviations:
|
||||
if not dev.principal.startswith("SharingLinks."):
|
||||
continue
|
||||
parts = dev.principal.split(".", 3)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
link_type = parts[2]
|
||||
if link_type not in payload.link_types:
|
||||
continue
|
||||
key = (dev.site_url, dev.principal)
|
||||
groups.setdefault(key, []).append(dev.id)
|
||||
|
||||
updated_deviations = 0
|
||||
for (site_url, group_name), dev_ids in groups.items():
|
||||
members = resolve_sharing_link_members(site_url, group_name, auth)
|
||||
resolved_members = ", ".join(members) if members else ""
|
||||
with SessionLocal() as db:
|
||||
for dev_id in dev_ids:
|
||||
dev = db.get(PermissionDeviation, dev_id)
|
||||
if dev:
|
||||
dev.resolved_members = resolved_members
|
||||
db.commit()
|
||||
updated_deviations += len(dev_ids)
|
||||
|
||||
return ResolveSharingLinksResponse(
|
||||
resolved_groups=len(groups),
|
||||
updated_deviations=updated_deviations,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/scan-jobs/{job_id}/export")
|
||||
def export_scan_job(job_id: str, site_url: str | None = None) -> StreamingResponse:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id, options=[joinedload(ScanJob.tenant_profile)])
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
targets_q = select(ScanTarget).where(ScanTarget.job_id == job.id).order_by(ScanTarget.id.asc())
|
||||
if site_url:
|
||||
targets_q = targets_q.where(ScanTarget.site_url == site_url)
|
||||
targets = list(db.execute(targets_q).scalars())
|
||||
|
||||
deviations_q = (
|
||||
select(PermissionDeviation)
|
||||
.where(PermissionDeviation.job_id == job.id)
|
||||
.order_by(PermissionDeviation.id.desc())
|
||||
)
|
||||
if site_url:
|
||||
deviations_q = deviations_q.where(PermissionDeviation.site_url == site_url)
|
||||
deviations = list(db.execute(deviations_q).scalars())
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
header_fill = PatternFill(start_color="1E2A3A", end_color="1E2A3A", fill_type="solid")
|
||||
header_font_white = Font(bold=True, color="FFFFFF")
|
||||
|
||||
_risk_styles: dict[str, tuple] = {
|
||||
"Critical": (
|
||||
PatternFill(start_color="FDDEDE", end_color="FDDEDE", fill_type="solid"),
|
||||
Font(bold=True, color="7B0000"),
|
||||
),
|
||||
"High": (
|
||||
PatternFill(start_color="FEE8D3", end_color="FEE8D3", fill_type="solid"),
|
||||
Font(bold=True, color="7C2D00"),
|
||||
),
|
||||
"Low": (
|
||||
PatternFill(start_color="D6EEF8", end_color="D6EEF8", fill_type="solid"),
|
||||
Font(bold=True, color="0C4A6E"),
|
||||
),
|
||||
"Unknown": (
|
||||
PatternFill(start_color="F0F0F0", end_color="F0F0F0", fill_type="solid"),
|
||||
Font(bold=True, color="555555"),
|
||||
),
|
||||
}
|
||||
|
||||
def _style_header(ws, headers):
|
||||
ws.append(headers)
|
||||
for cell in ws[1]:
|
||||
cell.font = header_font_white
|
||||
cell.fill = header_fill
|
||||
|
||||
# Targets sheet
|
||||
ws_targets = wb.active
|
||||
ws_targets.title = "Targets"
|
||||
_style_header(ws_targets, ["Site URL", "Status", "Attempts", "Error", "Started", "Finished"])
|
||||
for t in targets:
|
||||
ws_targets.append([
|
||||
t.site_url,
|
||||
t.status,
|
||||
t.attempts,
|
||||
t.error_message or "",
|
||||
t.started_at.isoformat() if t.started_at else "",
|
||||
t.finished_at.isoformat() if t.finished_at else "",
|
||||
])
|
||||
for col in ws_targets.columns:
|
||||
ws_targets.column_dimensions[col[0].column_letter].width = max(len(str(c.value or "")) for c in col) + 4
|
||||
|
||||
# Deviations sheet
|
||||
ws_dev = wb.create_sheet("Deviations")
|
||||
_style_header(ws_dev, ["Site URL", "Object URL", "Object Type", "Principal", "Link Risk", "Resolved Members", "Role", "Delta"])
|
||||
deviations.sort(key=lambda d: (d.site_url or "", d.object_url or "", d.principal or ""))
|
||||
for d in deviations:
|
||||
base = (d.site_url or "").rstrip("/")
|
||||
obj_rel = d.object_url[len(base):] if base and d.object_url.startswith(base) else d.object_url
|
||||
link_risk = _sharing_link_risk_label(d.principal)
|
||||
ws_dev.append([
|
||||
d.site_url,
|
||||
obj_rel,
|
||||
d.object_type,
|
||||
d.principal,
|
||||
link_risk,
|
||||
d.resolved_members or "",
|
||||
d.role_name,
|
||||
d.delta_type,
|
||||
])
|
||||
if link_risk in _risk_styles:
|
||||
risk_fill, risk_font = _risk_styles[link_risk]
|
||||
risk_cell = ws_dev.cell(row=ws_dev.max_row, column=5)
|
||||
risk_cell.fill = risk_fill
|
||||
risk_cell.font = risk_font
|
||||
for col in ws_dev.columns:
|
||||
ws_dev.column_dimensions[col[0].column_letter].width = max(len(str(c.value or "")) for c in col) + 4
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
|
||||
filename = f"clearview_job_{job_id}.xlsx"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/scan-jobs/{job_id}", response_model=ScanJobDetail)
|
||||
def get_scan_job(job_id: str) -> ScanJobDetail:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id, options=[joinedload(ScanJob.tenant_profile)])
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
targets = list(
|
||||
db.execute(select(ScanTarget).where(ScanTarget.job_id == job.id).order_by(ScanTarget.id.asc())).scalars()
|
||||
)
|
||||
deviations = list(
|
||||
db.execute(
|
||||
select(PermissionDeviation)
|
||||
.where(PermissionDeviation.job_id == job.id)
|
||||
.order_by(PermissionDeviation.id.desc())
|
||||
.limit(1000)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
return ScanJobDetail(
|
||||
**_to_job_summary(job).model_dump(),
|
||||
targets=[
|
||||
ScanTargetItem(
|
||||
id=t.id,
|
||||
site_url=t.site_url,
|
||||
status=t.status,
|
||||
attempts=t.attempts,
|
||||
error_message=t.error_message,
|
||||
started_at=t.started_at,
|
||||
finished_at=t.finished_at,
|
||||
)
|
||||
for t in targets
|
||||
],
|
||||
deviations=[
|
||||
PermissionDeviationItem(
|
||||
id=d.id,
|
||||
site_url=d.site_url,
|
||||
object_url=d.object_url,
|
||||
object_type=d.object_type,
|
||||
principal=d.principal,
|
||||
role_name=d.role_name,
|
||||
delta_type=d.delta_type,
|
||||
resolved_members=d.resolved_members,
|
||||
created_at=d.created_at,
|
||||
)
|
||||
for d in deviations
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Onboarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/onboarding/create-scan-app", response_model=CreateScanAppResponse)
|
||||
def onboarding_create_scan_app(payload: CreateScanAppRequest) -> CreateScanAppResponse:
|
||||
try:
|
||||
result = create_scan_app_for_tenant(
|
||||
tenant_id=payload.tenant_id,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
except OnboardingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected onboarding error: {exc}") from exc
|
||||
|
||||
return CreateScanAppResponse(
|
||||
tenant_id=result.tenant_id,
|
||||
client_id=result.client_id,
|
||||
client_secret=result.client_secret,
|
||||
app_object_id=result.app_object_id,
|
||||
service_principal_id=result.service_principal_id,
|
||||
display_name=result.display_name,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/onboarding/microsoft/connect-url", response_model=ConnectMicrosoftResponse)
|
||||
def onboarding_microsoft_connect_url() -> ConnectMicrosoftResponse:
|
||||
try:
|
||||
return ConnectMicrosoftResponse(connect_url=create_connect_url())
|
||||
except OnboardingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/api/onboarding/microsoft/callback")
|
||||
def onboarding_microsoft_callback(
|
||||
tenant: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
error_description: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
if error:
|
||||
message = (error_description or error).replace(" ", "+")
|
||||
return RedirectResponse(url=f"/?onboarding_status=error&onboarding_message={message}")
|
||||
|
||||
if not state or not consume_callback_state(state):
|
||||
return RedirectResponse(url="/?onboarding_status=error&onboarding_message=invalid_or_expired_state")
|
||||
|
||||
if not tenant:
|
||||
return RedirectResponse(url="/?onboarding_status=error&onboarding_message=missing_tenant")
|
||||
|
||||
return RedirectResponse(url=f"/?onboarding_status=connected&tenant_id={tenant}")
|
||||
|
||||
|
||||
@app.get("/api/onboarding/status")
|
||||
def onboarding_status() -> dict[str, bool]:
|
||||
from . import config
|
||||
automated = bool(config.ONBOARDING_CLIENT_ID and config.ONBOARDING_CLIENT_SECRET and config.ONBOARDING_REDIRECT_URI)
|
||||
return {"automated_available": automated}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(SITE_DIR / "index.html")
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=SITE_DIR, html=True), name="site")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_credentials(
|
||||
db,
|
||||
tenant_profile_id: str | None,
|
||||
tenant_id: str | None,
|
||||
client_id: str | None,
|
||||
client_secret: str | None,
|
||||
) -> tuple[str, str, str | None, str | None]:
|
||||
if tenant_profile_id:
|
||||
profile = db.get(TenantProfile, tenant_profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Tenant profile not found")
|
||||
if not profile.client_secret and not profile.cert_thumbprint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Tenant profile has no client secret and no certificate. Generate a certificate first.",
|
||||
)
|
||||
return profile.tenant_id, profile.client_id, profile.client_secret, tenant_profile_id
|
||||
if tenant_id and client_id and client_secret:
|
||||
return tenant_id.strip(), client_id.strip(), client_secret.strip(), None
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either tenant_profile_id or all of tenant_id, client_id, and client_secret.",
|
||||
)
|
||||
|
||||
|
||||
def _create_job_from_urls(
|
||||
raw_urls: list[str],
|
||||
skip_default_sites: bool,
|
||||
source_type: str,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
tenant_profile_id: str | None = None,
|
||||
) -> ScanJobCreateResponse:
|
||||
accepted_urls: list[str] = []
|
||||
skipped_default_urls: list[str] = []
|
||||
invalid_urls: list[str] = []
|
||||
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw in raw_urls:
|
||||
normalized = normalize_site_url(raw)
|
||||
if not normalized:
|
||||
invalid_urls.append(raw)
|
||||
continue
|
||||
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
|
||||
if skip_default_sites and is_default_site(normalized):
|
||||
skipped_default_urls.append(normalized)
|
||||
continue
|
||||
|
||||
accepted_urls.append(normalized)
|
||||
|
||||
with SessionLocal() as db:
|
||||
now = datetime.utcnow()
|
||||
job = ScanJob(
|
||||
id=str(uuid.uuid4()),
|
||||
source_type=source_type,
|
||||
status="queued" if accepted_urls else "completed",
|
||||
skip_default_sites=skip_default_sites,
|
||||
tenant_profile_id=tenant_profile_id,
|
||||
auth_tenant_id=tenant_id,
|
||||
auth_client_id=client_id,
|
||||
auth_client_secret=client_secret,
|
||||
total_targets=len(accepted_urls),
|
||||
skipped_targets=len(skipped_default_urls),
|
||||
warning_message=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
finished_at=now if not accepted_urls else None,
|
||||
)
|
||||
|
||||
if not accepted_urls:
|
||||
job.warning_message = "No scannable sites after validation and default-site filtering"
|
||||
|
||||
db.add(job)
|
||||
db.flush()
|
||||
|
||||
for index, site_url in enumerate(accepted_urls, start=1):
|
||||
db.add(
|
||||
ScanTarget(
|
||||
job_id=job.id,
|
||||
site_url=site_url,
|
||||
source_row=index,
|
||||
status="queued",
|
||||
attempts=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Reload with profile for summary
|
||||
stmt = select(ScanJob).options(joinedload(ScanJob.tenant_profile)).where(ScanJob.id == job.id)
|
||||
job = db.execute(stmt).unique().scalar_one()
|
||||
|
||||
return ScanJobCreateResponse(
|
||||
job=_to_job_summary(job),
|
||||
accepted_urls=accepted_urls,
|
||||
skipped_default_urls=skipped_default_urls,
|
||||
invalid_urls=invalid_urls,
|
||||
)
|
||||
|
||||
|
||||
def _to_job_summary(job: ScanJob) -> ScanJobSummary:
|
||||
return ScanJobSummary(
|
||||
id=job.id,
|
||||
status=job.status,
|
||||
source_type=job.source_type,
|
||||
skip_default_sites=job.skip_default_sites,
|
||||
tenant_profile_id=job.tenant_profile_id,
|
||||
tenant_name=job.tenant_profile.name if job.tenant_profile else None,
|
||||
total_targets=job.total_targets,
|
||||
processed_targets=job.processed_targets,
|
||||
successful_targets=job.successful_targets,
|
||||
failed_targets=job.failed_targets,
|
||||
skipped_targets=job.skipped_targets,
|
||||
items_scanned=job.items_scanned,
|
||||
scan_activity=job.scan_activity if job.status == "running" else None,
|
||||
warning_message=job.warning_message,
|
||||
error_message=job.error_message,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
started_at=job.started_at,
|
||||
finished_at=job.finished_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_tenant_item(profile: TenantProfile) -> TenantProfileItem:
|
||||
return TenantProfileItem(
|
||||
id=profile.id,
|
||||
name=profile.name,
|
||||
tenant_id=profile.tenant_id,
|
||||
client_id=profile.client_id,
|
||||
has_certificate=bool(profile.cert_thumbprint),
|
||||
cert_thumbprint=profile.cert_thumbprint,
|
||||
cert_expires_at=profile.cert_expires_at,
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _sharing_link_risk_label(principal: str) -> str:
|
||||
if not principal.startswith("SharingLinks."):
|
||||
return ""
|
||||
parts = principal.split(".", 3)
|
||||
link_type = parts[2] if len(parts) >= 3 else ""
|
||||
if link_type.startswith("Anonymous"):
|
||||
return "Critical"
|
||||
if link_type == "Flexible":
|
||||
return "High"
|
||||
if link_type.startswith("Organization"):
|
||||
return "Low"
|
||||
if link_type.startswith("Direct"):
|
||||
return "Low"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _ensure_schema_columns() -> None:
|
||||
stmts = [
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS auth_tenant_id VARCHAR(128)",
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS auth_client_id VARCHAR(128)",
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS auth_client_secret TEXT",
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS tenant_profile_id VARCHAR(36)",
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS items_scanned INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS scan_activity TEXT",
|
||||
"ALTER TABLE tenant_profiles ADD COLUMN IF NOT EXISTS client_secret TEXT",
|
||||
"ALTER TABLE tenant_profiles ALTER COLUMN client_secret DROP NOT NULL",
|
||||
"ALTER TABLE tenant_profiles ADD COLUMN IF NOT EXISTS cert_private_key TEXT",
|
||||
"ALTER TABLE tenant_profiles ADD COLUMN IF NOT EXISTS cert_thumbprint VARCHAR(64)",
|
||||
"ALTER TABLE tenant_profiles ADD COLUMN IF NOT EXISTS cert_expires_at TIMESTAMP",
|
||||
"ALTER TABLE permission_deviations ADD COLUMN IF NOT EXISTS resolved_members TEXT",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
conn.execute(text(stmt))
|
||||
106
containers/clearview/src/clearview_app/models.py
Normal file
106
containers/clearview/src/clearview_app/models.py
Normal file
@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TenantProfile(Base):
|
||||
__tablename__ = "tenant_profiles"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
tenant_id: Mapped[str] = mapped_column(String(128))
|
||||
client_id: Mapped[str] = mapped_column(String(128))
|
||||
client_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cert_private_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cert_thumbprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
cert_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
jobs: Mapped[list["ScanJob"]] = relationship(back_populates="tenant_profile")
|
||||
|
||||
|
||||
class ScanJob(Base):
|
||||
__tablename__ = "scan_jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="queued", index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(16), default="manual")
|
||||
skip_default_sites: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
tenant_profile_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("tenant_profiles.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
auth_tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
auth_client_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
auth_client_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
total_targets: Mapped[int] = mapped_column(Integer, default=0)
|
||||
processed_targets: Mapped[int] = mapped_column(Integer, default=0)
|
||||
successful_targets: Mapped[int] = mapped_column(Integer, default=0)
|
||||
failed_targets: Mapped[int] = mapped_column(Integer, default=0)
|
||||
skipped_targets: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
items_scanned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
scan_activity: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
warning_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
tenant_profile: Mapped["TenantProfile | None"] = relationship(back_populates="jobs")
|
||||
targets: Mapped[list["ScanTarget"]] = relationship(back_populates="job", cascade="all,delete-orphan")
|
||||
deviations: Mapped[list["PermissionDeviation"]] = relationship(back_populates="job", cascade="all,delete-orphan")
|
||||
|
||||
|
||||
class ScanTarget(Base):
|
||||
__tablename__ = "scan_targets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
job_id: Mapped[str] = mapped_column(ForeignKey("scan_jobs.id", ondelete="CASCADE"), index=True)
|
||||
site_url: Mapped[str] = mapped_column(Text)
|
||||
source_row: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(32), default="queued", index=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
job: Mapped[ScanJob] = relationship(back_populates="targets")
|
||||
deviations: Mapped[list["PermissionDeviation"]] = relationship(back_populates="target", cascade="all,delete-orphan")
|
||||
|
||||
|
||||
class PermissionDeviation(Base):
|
||||
__tablename__ = "permission_deviations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
job_id: Mapped[str] = mapped_column(ForeignKey("scan_jobs.id", ondelete="CASCADE"), index=True)
|
||||
target_id: Mapped[int] = mapped_column(ForeignKey("scan_targets.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
site_url: Mapped[str] = mapped_column(Text)
|
||||
object_url: Mapped[str] = mapped_column(Text)
|
||||
object_type: Mapped[str] = mapped_column(String(64))
|
||||
principal: Mapped[str] = mapped_column(Text)
|
||||
role_name: Mapped[str] = mapped_column(Text)
|
||||
delta_type: Mapped[str] = mapped_column(String(32))
|
||||
resolved_members: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
job: Mapped[ScanJob] = relationship(back_populates="deviations")
|
||||
target: Mapped[ScanTarget] = relationship(back_populates="deviations")
|
||||
247
containers/clearview/src/clearview_app/onboarding.py
Normal file
247
containers/clearview/src/clearview_app/onboarding.py
Normal file
@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from .config import ONBOARDING_CLIENT_ID, ONBOARDING_CLIENT_SECRET, ONBOARDING_REDIRECT_URI
|
||||
|
||||
SHAREPOINT_RESOURCE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
|
||||
_STATE_TTL_SEC = 600
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreatedScanApp:
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
app_object_id: str
|
||||
service_principal_id: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OnboardingError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_state_store: dict[str, float] = {}
|
||||
|
||||
|
||||
def create_connect_url() -> str:
|
||||
_validate_onboarding_config()
|
||||
state = _issue_state_token()
|
||||
|
||||
query = {
|
||||
"client_id": ONBOARDING_CLIENT_ID,
|
||||
"redirect_uri": ONBOARDING_REDIRECT_URI,
|
||||
"state": state,
|
||||
}
|
||||
return f"https://login.microsoftonline.com/organizations/adminconsent?{urlencode(query)}"
|
||||
|
||||
|
||||
def consume_callback_state(state: str) -> bool:
|
||||
_cleanup_states()
|
||||
created_at = _state_store.pop(state, None)
|
||||
if not created_at:
|
||||
return False
|
||||
return (time.time() - created_at) <= _STATE_TTL_SEC
|
||||
|
||||
|
||||
def create_scan_app_for_tenant(tenant_id: str, display_name: str) -> CreatedScanApp:
|
||||
tenant = (tenant_id or "").strip()
|
||||
app_name = (display_name or "").strip() or f"Clearview Scan App {uuid.uuid4().hex[:8]}"
|
||||
|
||||
if not tenant:
|
||||
raise OnboardingError("tenant_id is required")
|
||||
|
||||
_validate_onboarding_config()
|
||||
|
||||
graph_token = _get_graph_token_for_tenant(tenant)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {graph_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
sharepoint_sp = _get_service_principal_by_app_id(headers, SHAREPOINT_RESOURCE_APP_ID)
|
||||
if not sharepoint_sp:
|
||||
raise OnboardingError("Could not resolve SharePoint service principal in tenant")
|
||||
|
||||
sites_full_control_role_id = _find_app_role_id(sharepoint_sp, "Sites.FullControl.All")
|
||||
if not sites_full_control_role_id:
|
||||
raise OnboardingError("SharePoint app role 'Sites.FullControl.All' not found")
|
||||
|
||||
app_payload = {
|
||||
"displayName": app_name,
|
||||
"signInAudience": "AzureADMyOrg",
|
||||
"requiredResourceAccess": [
|
||||
{
|
||||
"resourceAppId": SHAREPOINT_RESOURCE_APP_ID,
|
||||
"resourceAccess": [
|
||||
{"id": sites_full_control_role_id, "type": "Role"}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
app = _graph_request("POST", "https://graph.microsoft.com/v1.0/applications", headers, json=app_payload)
|
||||
app_object_id = str(app.get("id") or "")
|
||||
app_client_id = str(app.get("appId") or "")
|
||||
if not app_object_id or not app_client_id:
|
||||
raise OnboardingError("Graph did not return app id/appId")
|
||||
|
||||
sp_payload = {"appId": app_client_id}
|
||||
sp = _graph_request("POST", "https://graph.microsoft.com/v1.0/servicePrincipals", headers, json=sp_payload)
|
||||
sp_id = str(sp.get("id") or "")
|
||||
if not sp_id:
|
||||
raise OnboardingError("Graph did not return service principal id")
|
||||
|
||||
_grant_application_role(
|
||||
headers=headers,
|
||||
target_resource_sp_id=str(sharepoint_sp["id"]),
|
||||
client_service_principal_id=sp_id,
|
||||
app_role_id=sites_full_control_role_id,
|
||||
)
|
||||
|
||||
add_password_payload = {
|
||||
"passwordCredential": {
|
||||
"displayName": "clearview-generated-secret",
|
||||
"endDateTime": "2030-12-31T23:59:59Z",
|
||||
}
|
||||
}
|
||||
secret_result = _graph_request(
|
||||
"POST",
|
||||
f"https://graph.microsoft.com/v1.0/applications/{app_object_id}/addPassword",
|
||||
headers,
|
||||
json=add_password_payload,
|
||||
)
|
||||
generated_secret = str(secret_result.get("secretText") or "")
|
||||
if not generated_secret:
|
||||
raise OnboardingError("Graph did not return generated client secret")
|
||||
|
||||
return CreatedScanApp(
|
||||
tenant_id=tenant,
|
||||
client_id=app_client_id,
|
||||
client_secret=generated_secret,
|
||||
app_object_id=app_object_id,
|
||||
service_principal_id=sp_id,
|
||||
display_name=app_name,
|
||||
)
|
||||
|
||||
|
||||
def _issue_state_token() -> str:
|
||||
_cleanup_states()
|
||||
token = secrets.token_urlsafe(24)
|
||||
_state_store[token] = time.time()
|
||||
return token
|
||||
|
||||
|
||||
def _cleanup_states() -> None:
|
||||
now = time.time()
|
||||
expired = [key for key, created in _state_store.items() if (now - created) > _STATE_TTL_SEC]
|
||||
for key in expired:
|
||||
_state_store.pop(key, None)
|
||||
|
||||
|
||||
def _validate_onboarding_config() -> None:
|
||||
missing = []
|
||||
if not ONBOARDING_CLIENT_ID:
|
||||
missing.append("ONBOARDING_CLIENT_ID")
|
||||
if not ONBOARDING_CLIENT_SECRET:
|
||||
missing.append("ONBOARDING_CLIENT_SECRET")
|
||||
if not ONBOARDING_REDIRECT_URI:
|
||||
missing.append("ONBOARDING_REDIRECT_URI")
|
||||
if missing:
|
||||
raise OnboardingError("Missing onboarding config: " + ", ".join(missing))
|
||||
|
||||
|
||||
def _get_graph_token_for_tenant(tenant_id: str) -> str:
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"client_id": ONBOARDING_CLIENT_ID,
|
||||
"client_secret": ONBOARDING_CLIENT_SECRET,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "https://graph.microsoft.com/.default",
|
||||
}
|
||||
response = requests.post(token_url, data=data, timeout=30)
|
||||
if response.status_code >= 400:
|
||||
raise OnboardingError(f"Graph token request failed ({response.status_code}): {response.text[:400]}")
|
||||
|
||||
payload = response.json()
|
||||
token = str(payload.get("access_token") or "")
|
||||
if not token:
|
||||
raise OnboardingError("Graph token response missing access_token")
|
||||
return token
|
||||
|
||||
|
||||
def _graph_request(method: str, url: str, headers: dict[str, str], json: dict | None = None) -> dict:
|
||||
last_error = ""
|
||||
for attempt in range(1, 5):
|
||||
response = requests.request(method=method, url=url, headers=headers, json=json, timeout=30)
|
||||
if response.status_code in (429, 503):
|
||||
retry_after = _int_value(response.headers.get("Retry-After"))
|
||||
time.sleep(retry_after if retry_after > 0 else attempt * 2)
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
last_error = f"{response.status_code}: {response.text[:500]}"
|
||||
if response.status_code >= 500 and attempt < 4:
|
||||
time.sleep(attempt * 2)
|
||||
continue
|
||||
raise OnboardingError(f"Graph request failed for {url}: {last_error}")
|
||||
|
||||
if response.text.strip():
|
||||
return response.json()
|
||||
return {}
|
||||
|
||||
raise OnboardingError(f"Graph request failed for {url}: {last_error}")
|
||||
|
||||
|
||||
def _get_service_principal_by_app_id(headers: dict[str, str], app_id: str) -> dict | None:
|
||||
url = f"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{app_id}'"
|
||||
payload = _graph_request("GET", url, headers)
|
||||
values = payload.get("value")
|
||||
if isinstance(values, list) and values:
|
||||
return values[0]
|
||||
return None
|
||||
|
||||
|
||||
def _find_app_role_id(resource_sp: dict, role_value: str) -> str | None:
|
||||
app_roles = resource_sp.get("appRoles")
|
||||
if not isinstance(app_roles, list):
|
||||
return None
|
||||
|
||||
for role in app_roles:
|
||||
if not isinstance(role, dict):
|
||||
continue
|
||||
if str(role.get("value") or "") != role_value:
|
||||
continue
|
||||
if not bool(role.get("isEnabled", False)):
|
||||
continue
|
||||
role_id = str(role.get("id") or "")
|
||||
if role_id:
|
||||
return role_id
|
||||
return None
|
||||
|
||||
|
||||
def _grant_application_role(
|
||||
headers: dict[str, str],
|
||||
target_resource_sp_id: str,
|
||||
client_service_principal_id: str,
|
||||
app_role_id: str,
|
||||
) -> None:
|
||||
url = f"https://graph.microsoft.com/v1.0/servicePrincipals/{target_resource_sp_id}/appRoleAssignedTo"
|
||||
payload = {
|
||||
"principalId": client_service_principal_id,
|
||||
"resourceId": target_resource_sp_id,
|
||||
"appRoleId": app_role_id,
|
||||
}
|
||||
_graph_request("POST", url, headers, json=payload)
|
||||
|
||||
|
||||
def _int_value(value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
467
containers/clearview/src/clearview_app/scanner.py
Normal file
467
containers/clearview/src/clearview_app/scanner.py
Normal file
@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import msal
|
||||
import requests
|
||||
|
||||
from .config import (
|
||||
SCAN_HTTP_BACKOFF_SEC,
|
||||
SCAN_HTTP_MAX_RETRIES,
|
||||
SCAN_HTTP_TIMEOUT_SEC,
|
||||
SCAN_LIST_PAGE_SIZE,
|
||||
SCAN_MAX_ITEMS_PER_LIST,
|
||||
SHAREPOINT_SCAN_MODE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviationRecord:
|
||||
object_url: str
|
||||
object_type: str
|
||||
principal: str
|
||||
role_name: str
|
||||
delta_type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
deviations: list[DeviationRecord]
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionEntry:
|
||||
principal: str
|
||||
role_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
client_secret: str = ""
|
||||
cert_private_key: str | None = None
|
||||
cert_thumbprint: str | None = None
|
||||
|
||||
|
||||
_TOKEN_CACHE: dict[str, str] = {}
|
||||
|
||||
|
||||
ProgressCallback = Callable[[str, int], None]
|
||||
|
||||
|
||||
def scan_site_for_deviations(
|
||||
site_url: str,
|
||||
auth: AuthConfig,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> ScanResult:
|
||||
"""
|
||||
Scan SharePoint permission deviations versus site-root role assignments.
|
||||
|
||||
Only SharePoint role assignments are used (site/list/folder/file scope).
|
||||
No filesystem/NTFS permission model is used.
|
||||
"""
|
||||
if SHAREPOINT_SCAN_MODE == "placeholder":
|
||||
return ScanResult(
|
||||
deviations=[],
|
||||
warning=(
|
||||
"SharePoint scan mode is 'placeholder'. "
|
||||
"Set SHAREPOINT_SCAN_MODE=sharepoint_app_only and configure Azure app credentials."
|
||||
),
|
||||
)
|
||||
|
||||
if SHAREPOINT_SCAN_MODE != "sharepoint_app_only":
|
||||
raise RuntimeError(f"Unsupported SHAREPOINT_SCAN_MODE='{SHAREPOINT_SCAN_MODE}'")
|
||||
|
||||
_validate_auth_config(auth)
|
||||
|
||||
def _report(activity: str, items: int = 0) -> None:
|
||||
if progress:
|
||||
progress(activity, items)
|
||||
|
||||
parsed = urlparse(site_url)
|
||||
host = parsed.netloc
|
||||
_report(f"Connecting to {host}")
|
||||
token = _get_token_for_host(host, auth)
|
||||
|
||||
base_headers = {
|
||||
"Accept": "application/json;odata=nometadata",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
|
||||
_report(f"Loading site permissions: {site_url}")
|
||||
root_assignments = _get_role_assignments(
|
||||
f"{site_url}/_api/web/roleassignments?$expand=Member,RoleDefinitionBindings"
|
||||
"&$select=Member/LoginName,Member/Title,Member/PrincipalType,RoleDefinitionBindings/Name",
|
||||
base_headers,
|
||||
)
|
||||
root_set = set(root_assignments)
|
||||
|
||||
deviations: list[DeviationRecord] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
lists_url = (
|
||||
f"{site_url}/_api/web/lists"
|
||||
"?$select=Id,Title,BaseTemplate,Hidden,ItemCount,RootFolder/ServerRelativeUrl,HasUniqueRoleAssignments"
|
||||
"&$expand=RootFolder"
|
||||
)
|
||||
for lst in _iter_paged(lists_url, base_headers):
|
||||
if _to_bool(lst.get("Hidden")):
|
||||
continue
|
||||
if _to_int(lst.get("BaseTemplate")) != 101:
|
||||
continue
|
||||
|
||||
list_id = str(lst.get("Id", "")).strip()
|
||||
if not list_id:
|
||||
continue
|
||||
|
||||
list_title = str(lst.get("Title") or "Document Library")
|
||||
list_url = _absolute_url(host, str((lst.get("RootFolder") or {}).get("ServerRelativeUrl") or ""))
|
||||
|
||||
_report(f"Library: {list_title}")
|
||||
|
||||
if _to_bool(lst.get("HasUniqueRoleAssignments")):
|
||||
list_assignments = _get_role_assignments(
|
||||
f"{site_url}/_api/web/lists(guid'{list_id}')/roleassignments"
|
||||
"?$expand=Member,RoleDefinitionBindings"
|
||||
"&$select=Member/LoginName,Member/Title,Member/PrincipalType,RoleDefinitionBindings/Name",
|
||||
base_headers,
|
||||
)
|
||||
deviations.extend(
|
||||
_deviation_records_only_added(
|
||||
object_url=list_url,
|
||||
object_type="DocumentLibrary",
|
||||
root_set=root_set,
|
||||
current_set=set(list_assignments),
|
||||
)
|
||||
)
|
||||
|
||||
items_processed = 0
|
||||
items_total = 0
|
||||
items_url = (
|
||||
f"{site_url}/_api/web/lists(guid'{list_id}')/items"
|
||||
f"?$select=Id,FileRef,FileSystemObjectType,HasUniqueRoleAssignments&$top={SCAN_LIST_PAGE_SIZE}"
|
||||
)
|
||||
|
||||
for item in _iter_paged(items_url, base_headers):
|
||||
items_total += 1
|
||||
if items_total % 50 == 0:
|
||||
_report(f"Library: {list_title} ({items_total} items scanned)", 50)
|
||||
|
||||
if not _to_bool(item.get("HasUniqueRoleAssignments")):
|
||||
continue
|
||||
|
||||
if items_processed >= SCAN_MAX_ITEMS_PER_LIST:
|
||||
warnings.append(
|
||||
f"List '{list_title}' hit SCAN_MAX_ITEMS_PER_LIST={SCAN_MAX_ITEMS_PER_LIST}; remaining unique-permission items skipped"
|
||||
)
|
||||
break
|
||||
|
||||
item_id = _to_int(item.get("Id"))
|
||||
if item_id <= 0:
|
||||
continue
|
||||
|
||||
file_ref = str(item.get("FileRef") or "")
|
||||
if not file_ref:
|
||||
continue
|
||||
|
||||
item_type = "File" if _to_int(item.get("FileSystemObjectType")) == 0 else "Folder"
|
||||
|
||||
item_assignments = _get_role_assignments(
|
||||
f"{site_url}/_api/web/lists(guid'{list_id}')/items({item_id})/roleassignments"
|
||||
"?$expand=Member,RoleDefinitionBindings"
|
||||
"&$select=Member/LoginName,Member/Title,Member/PrincipalType,RoleDefinitionBindings/Name",
|
||||
base_headers,
|
||||
)
|
||||
deviations.extend(
|
||||
_deviation_records_only_added(
|
||||
object_url=_absolute_url(host, file_ref),
|
||||
object_type=item_type,
|
||||
root_set=root_set,
|
||||
current_set=set(item_assignments),
|
||||
)
|
||||
)
|
||||
items_processed += 1
|
||||
|
||||
_report("Scan complete", 0)
|
||||
warning = " | ".join(warnings) if warnings else None
|
||||
return ScanResult(deviations=_deduplicate_hierarchical(deviations), warning=warning)
|
||||
|
||||
|
||||
def resolve_sharing_link_members(
|
||||
site_url: str,
|
||||
group_name: str,
|
||||
auth: AuthConfig,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Return the members of a SharePoint SharingLinks group.
|
||||
Returns an empty list for anonymous links (no resolvable members).
|
||||
"""
|
||||
_validate_auth_config(auth)
|
||||
parsed = urlparse(site_url)
|
||||
host = parsed.netloc
|
||||
token = _get_token_for_host(host, auth)
|
||||
headers = {
|
||||
"Accept": "application/json;odata=nometadata",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
encoded = group_name.replace("'", "''")
|
||||
url = (
|
||||
f"{site_url}/_api/web/sitegroups/getbyname('{encoded}')/users"
|
||||
"?$select=LoginName,Email,Title"
|
||||
)
|
||||
try:
|
||||
data = _request_json(url, headers)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
members: list[str] = []
|
||||
for user in _extract_values(data):
|
||||
email = str(user.get("Email") or "").strip()
|
||||
login = str(user.get("LoginName") or "").strip()
|
||||
title = str(user.get("Title") or "").strip()
|
||||
# Skip built-in SharePoint system accounts
|
||||
if login.upper().startswith("SHAREPOINT\\") or login.startswith("c:0(.s|true"):
|
||||
continue
|
||||
if email:
|
||||
members.append(email)
|
||||
elif title:
|
||||
members.append(title)
|
||||
elif login:
|
||||
members.append(login)
|
||||
return members
|
||||
|
||||
|
||||
def _validate_auth_config(auth: AuthConfig) -> None:
|
||||
missing = []
|
||||
if not auth.tenant_id:
|
||||
missing.append("tenant_id")
|
||||
if not auth.client_id:
|
||||
missing.append("client_id")
|
||||
if not auth.client_secret and not (auth.cert_thumbprint and auth.cert_private_key):
|
||||
missing.append("client_secret or certificate")
|
||||
|
||||
if missing:
|
||||
raise RuntimeError("Missing required Azure auth settings: " + ", ".join(missing))
|
||||
|
||||
|
||||
def _get_token_for_host(host: str, auth: AuthConfig) -> str:
|
||||
auth_method = "cert" if auth.cert_thumbprint and auth.cert_private_key else "secret"
|
||||
cache_key = f"{host}|{auth.tenant_id}|{auth.client_id}|{auth_method}"
|
||||
cached = _TOKEN_CACHE.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
scope = f"https://{host}/.default"
|
||||
authority = f"https://login.microsoftonline.com/{auth.tenant_id}"
|
||||
|
||||
if auth_method == "cert":
|
||||
client_credential = {
|
||||
"thumbprint": auth.cert_thumbprint,
|
||||
"private_key": auth.cert_private_key,
|
||||
}
|
||||
else:
|
||||
client_credential = auth.client_secret
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=auth.client_id,
|
||||
authority=authority,
|
||||
client_credential=client_credential,
|
||||
)
|
||||
result = app.acquire_token_for_client(scopes=[scope])
|
||||
|
||||
if "access_token" not in result:
|
||||
error = result.get("error", "unknown")
|
||||
description = result.get("error_description", "")
|
||||
raise RuntimeError(f"Token request failed ({error}): {description[:300]}")
|
||||
|
||||
token = str(result["access_token"])
|
||||
_TOKEN_CACHE[cache_key] = token
|
||||
return token
|
||||
|
||||
|
||||
def _iter_paged(url: str, headers: dict[str, str]):
|
||||
next_url = url
|
||||
while next_url:
|
||||
data = _request_json(next_url, headers)
|
||||
for item in _extract_values(data):
|
||||
yield item
|
||||
next_url = _extract_next_link(data)
|
||||
|
||||
|
||||
def _request_json(url: str, headers: dict[str, str]) -> dict:
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, SCAN_HTTP_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=SCAN_HTTP_TIMEOUT_SEC)
|
||||
if response.status_code in (429, 503):
|
||||
retry_after = _to_int(response.headers.get("Retry-After"))
|
||||
delay = retry_after if retry_after > 0 else SCAN_HTTP_BACKOFF_SEC * attempt
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {response.status_code}: {response.text[:300]}")
|
||||
return response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = str(exc)
|
||||
if attempt < SCAN_HTTP_MAX_RETRIES:
|
||||
time.sleep(SCAN_HTTP_BACKOFF_SEC * attempt)
|
||||
continue
|
||||
raise RuntimeError(f"Request failed for {url}: {last_error}") from exc
|
||||
|
||||
raise RuntimeError(f"Request failed for {url}: {last_error}")
|
||||
|
||||
|
||||
def _extract_values(data: dict) -> list[dict]:
|
||||
if "value" in data and isinstance(data["value"], list):
|
||||
return data["value"]
|
||||
d = data.get("d")
|
||||
if isinstance(d, dict):
|
||||
results = d.get("results")
|
||||
if isinstance(results, list):
|
||||
return results
|
||||
return []
|
||||
|
||||
|
||||
def _extract_next_link(data: dict) -> str | None:
|
||||
for key in ("@odata.nextLink", "odata.nextLink", "__next"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
d = data.get("d")
|
||||
if isinstance(d, dict):
|
||||
value = d.get("__next")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _get_role_assignments(url: str, headers: dict[str, str]) -> list[PermissionEntry]:
|
||||
data = _request_json(url, headers)
|
||||
assignments: list[PermissionEntry] = []
|
||||
|
||||
for item in _extract_values(data):
|
||||
member = item.get("Member") or {}
|
||||
principal = str(member.get("LoginName") or member.get("Title") or "").strip()
|
||||
if not principal:
|
||||
continue
|
||||
|
||||
role_bindings = item.get("RoleDefinitionBindings")
|
||||
roles = _extract_role_names(role_bindings)
|
||||
for role_name in roles:
|
||||
if role_name.lower() == "limited access":
|
||||
continue
|
||||
assignments.append(PermissionEntry(principal=principal, role_name=role_name))
|
||||
|
||||
return assignments
|
||||
|
||||
|
||||
_ROLE_NAME_NL_TO_EN: dict[str, str] = {
|
||||
"volledig beheer": "Full Control",
|
||||
"ontwerpen": "Design",
|
||||
"bewerken": "Edit",
|
||||
"bijdragen": "Contribute",
|
||||
"lezen": "Read",
|
||||
"beperkte toegang": "Limited Access",
|
||||
"goedkeuren": "Approve",
|
||||
"hiërarchieën beheren": "Manage Hierarchy",
|
||||
"weergeven alleen": "View Only",
|
||||
"beperkt lezen": "Restricted Read",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_role_name(name: str) -> str:
|
||||
return _ROLE_NAME_NL_TO_EN.get(name.lower(), name)
|
||||
|
||||
|
||||
def _extract_role_names(bindings) -> list[str]:
|
||||
if isinstance(bindings, list):
|
||||
return [_normalize_role_name(str(x.get("Name") or "").strip()) for x in bindings if isinstance(x, dict) and x.get("Name")]
|
||||
if isinstance(bindings, dict):
|
||||
results = bindings.get("results")
|
||||
if isinstance(results, list):
|
||||
return [_normalize_role_name(str(x.get("Name") or "").strip()) for x in results if isinstance(x, dict) and x.get("Name")]
|
||||
return []
|
||||
|
||||
|
||||
def _deduplicate_hierarchical(deviations: list[DeviationRecord]) -> list[DeviationRecord]:
|
||||
"""
|
||||
Remove child-level deviations that are already covered by a parent in the URL hierarchy.
|
||||
|
||||
A deviation for (principal, role) at /sites/X/Lib/FolderA is redundant when the same
|
||||
(principal, role) was already reported at /sites/X/Lib or /sites/X/Lib/FolderA's parent.
|
||||
Sorting by URL length ascending guarantees parents are evaluated before their children.
|
||||
"""
|
||||
sorted_devs = sorted(deviations, key=lambda d: len(d.object_url))
|
||||
# Maps (principal, role_name) → list of ancestor URLs already reported
|
||||
covered: dict[tuple[str, str], list[str]] = {}
|
||||
result: list[DeviationRecord] = []
|
||||
|
||||
for dev in sorted_devs:
|
||||
key = (dev.principal, dev.role_name)
|
||||
ancestor_urls = covered.get(key)
|
||||
if ancestor_urls:
|
||||
parent = dev.object_url.rstrip("/")
|
||||
already_covered = any(
|
||||
parent == anc.rstrip("/") or parent.startswith(anc.rstrip("/") + "/")
|
||||
for anc in ancestor_urls
|
||||
)
|
||||
if already_covered:
|
||||
continue
|
||||
else:
|
||||
covered[key] = []
|
||||
result.append(dev)
|
||||
covered[key].append(dev.object_url)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _deviation_records_only_added(
|
||||
object_url: str,
|
||||
object_type: str,
|
||||
root_set: set[PermissionEntry],
|
||||
current_set: set[PermissionEntry],
|
||||
) -> list[DeviationRecord]:
|
||||
records: list[DeviationRecord] = []
|
||||
for entry in sorted(current_set - root_set, key=lambda x: (x.principal.lower(), x.role_name.lower())):
|
||||
records.append(
|
||||
DeviationRecord(
|
||||
object_url=object_url,
|
||||
object_type=object_type,
|
||||
principal=entry.principal,
|
||||
role_name=entry.role_name,
|
||||
delta_type="added",
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _absolute_url(host: str, server_relative_url: str) -> str:
|
||||
if not server_relative_url:
|
||||
return f"https://{host}"
|
||||
if server_relative_url.startswith("http://") or server_relative_url.startswith("https://"):
|
||||
return server_relative_url
|
||||
if not server_relative_url.startswith("/"):
|
||||
server_relative_url = "/" + server_relative_url
|
||||
return f"https://{host}{server_relative_url}"
|
||||
|
||||
|
||||
def _to_int(value) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 0
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _to_bool(value) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes")
|
||||
return bool(value)
|
||||
125
containers/clearview/src/clearview_app/schemas.py
Normal file
125
containers/clearview/src/clearview_app/schemas.py
Normal file
@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class CreateTenantProfileRequest(BaseModel):
|
||||
name: str
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
|
||||
|
||||
class TenantProfileItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
has_certificate: bool
|
||||
cert_thumbprint: str | None
|
||||
cert_expires_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TenantCertificateResponse(BaseModel):
|
||||
thumbprint: str
|
||||
expires_at: datetime
|
||||
public_cert_pem: str
|
||||
|
||||
|
||||
class CreateScanJobRequest(BaseModel):
|
||||
site_urls: list[HttpUrl] = Field(default_factory=list)
|
||||
skip_default_sites: bool = True
|
||||
tenant_profile_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
|
||||
|
||||
class ScanJobSummary(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
source_type: str
|
||||
skip_default_sites: bool
|
||||
tenant_profile_id: str | None
|
||||
tenant_name: str | None
|
||||
|
||||
total_targets: int
|
||||
processed_targets: int
|
||||
successful_targets: int
|
||||
failed_targets: int
|
||||
skipped_targets: int
|
||||
items_scanned: int
|
||||
scan_activity: str | None
|
||||
|
||||
warning_message: str | None
|
||||
error_message: str | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class ScanTargetItem(BaseModel):
|
||||
id: int
|
||||
site_url: str
|
||||
status: str
|
||||
attempts: int
|
||||
error_message: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class PermissionDeviationItem(BaseModel):
|
||||
id: int
|
||||
site_url: str
|
||||
object_url: str
|
||||
object_type: str
|
||||
principal: str
|
||||
role_name: str
|
||||
delta_type: str
|
||||
resolved_members: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ResolveSharingLinksRequest(BaseModel):
|
||||
link_types: list[str]
|
||||
|
||||
|
||||
class ResolveSharingLinksResponse(BaseModel):
|
||||
resolved_groups: int
|
||||
updated_deviations: int
|
||||
|
||||
|
||||
class ScanJobDetail(ScanJobSummary):
|
||||
targets: list[ScanTargetItem]
|
||||
deviations: list[PermissionDeviationItem]
|
||||
|
||||
|
||||
class ScanJobCreateResponse(BaseModel):
|
||||
job: ScanJobSummary
|
||||
accepted_urls: list[str]
|
||||
skipped_default_urls: list[str]
|
||||
invalid_urls: list[str]
|
||||
|
||||
|
||||
class CreateScanAppRequest(BaseModel):
|
||||
tenant_id: str
|
||||
display_name: str = "Clearview Scan App"
|
||||
|
||||
|
||||
class ConnectMicrosoftResponse(BaseModel):
|
||||
connect_url: str
|
||||
|
||||
|
||||
class CreateScanAppResponse(BaseModel):
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
app_object_id: str
|
||||
service_principal_id: str
|
||||
display_name: str
|
||||
243
containers/clearview/src/clearview_app/worker.py
Normal file
243
containers/clearview/src/clearview_app/worker.py
Normal file
@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import (
|
||||
SCAN_JOB_POLL_INTERVAL_SEC,
|
||||
SCAN_RETRY_BASE_DELAY_SEC,
|
||||
SCAN_TARGET_MAX_RETRIES,
|
||||
SCAN_TARGET_TIMEOUT_SEC,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import PermissionDeviation, ScanJob, ScanTarget, TenantProfile
|
||||
from .scanner import AuthConfig, scan_site_for_deviations
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScanWorker:
|
||||
def __init__(self) -> None:
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name="scan-worker", daemon=True)
|
||||
self._thread.start()
|
||||
log.info("scan worker started")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
log.info("scan worker stopped")
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
did_work = self._process_next_job()
|
||||
if not did_work:
|
||||
self._stop_event.wait(SCAN_JOB_POLL_INTERVAL_SEC)
|
||||
|
||||
def _process_next_job(self) -> bool:
|
||||
with SessionLocal() as db:
|
||||
job = db.execute(
|
||||
select(ScanJob)
|
||||
.where(ScanJob.status == "queued")
|
||||
.order_by(ScanJob.created_at.asc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if job is None:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
job.status = "running"
|
||||
job.started_at = now
|
||||
job.heartbeat_at = now
|
||||
job.updated_at = now
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
job_id = job.id
|
||||
|
||||
self._run_job(job_id)
|
||||
return True
|
||||
|
||||
def _run_job(self, job_id: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
targets = list(
|
||||
db.execute(
|
||||
select(ScanTarget)
|
||||
.where(ScanTarget.job_id == job_id)
|
||||
.order_by(ScanTarget.id.asc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
for target in targets:
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
if not job or job.status == "cancelled":
|
||||
return
|
||||
self._process_target(job_id, target.id)
|
||||
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
if not job:
|
||||
return
|
||||
now = datetime.utcnow()
|
||||
job.heartbeat_at = now
|
||||
job.updated_at = now
|
||||
job.finished_at = now
|
||||
if job.failed_targets > 0:
|
||||
job.status = "completed_with_errors"
|
||||
else:
|
||||
job.status = "completed"
|
||||
db.commit()
|
||||
|
||||
def _process_target(self, job_id: str, target_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
target = db.get(ScanTarget, target_id)
|
||||
if not job or not target:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
target.status = "running"
|
||||
target.started_at = now
|
||||
target.updated_at = now
|
||||
job.heartbeat_at = now
|
||||
job.updated_at = now
|
||||
db.commit()
|
||||
|
||||
max_attempts = SCAN_TARGET_MAX_RETRIES + 1
|
||||
last_error: str | None = None
|
||||
latest_warning: str | None = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
result = self._scan_with_timeout(target_id, SCAN_TARGET_TIMEOUT_SEC)
|
||||
latest_warning = result.warning
|
||||
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
target = db.get(ScanTarget, target_id)
|
||||
if not job or not target:
|
||||
return
|
||||
|
||||
for deviation in result.deviations:
|
||||
db.add(
|
||||
PermissionDeviation(
|
||||
job_id=job.id,
|
||||
target_id=target.id,
|
||||
site_url=target.site_url,
|
||||
object_url=deviation.object_url,
|
||||
object_type=deviation.object_type,
|
||||
principal=deviation.principal,
|
||||
role_name=deviation.role_name,
|
||||
delta_type=deviation.delta_type,
|
||||
)
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
target.status = "completed"
|
||||
target.attempts = attempt
|
||||
target.error_message = None
|
||||
target.finished_at = now
|
||||
target.updated_at = now
|
||||
|
||||
job.processed_targets += 1
|
||||
job.successful_targets += 1
|
||||
job.heartbeat_at = now
|
||||
job.updated_at = now
|
||||
if latest_warning:
|
||||
job.warning_message = latest_warning
|
||||
|
||||
db.commit()
|
||||
return
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = str(exc)
|
||||
if attempt < max_attempts:
|
||||
delay = SCAN_RETRY_BASE_DELAY_SEC * (2 ** (attempt - 1))
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, job_id)
|
||||
target = db.get(ScanTarget, target_id)
|
||||
if not job or not target:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
target.status = "failed"
|
||||
target.attempts = max_attempts
|
||||
target.error_message = last_error
|
||||
target.finished_at = now
|
||||
target.updated_at = now
|
||||
|
||||
job.processed_targets += 1
|
||||
job.failed_targets += 1
|
||||
job.heartbeat_at = now
|
||||
job.updated_at = now
|
||||
if not job.error_message:
|
||||
job.error_message = "One or more scan targets failed"
|
||||
|
||||
db.commit()
|
||||
|
||||
def _scan_with_timeout(self, target_id: int, timeout_sec: int):
|
||||
with SessionLocal() as db:
|
||||
target = db.get(ScanTarget, target_id)
|
||||
if not target:
|
||||
raise RuntimeError(f"Target {target_id} not found")
|
||||
site_url = target.site_url
|
||||
job = db.get(ScanJob, target.job_id)
|
||||
if not job:
|
||||
raise RuntimeError(f"Job {target.job_id} not found for target {target_id}")
|
||||
cert_private_key: str | None = None
|
||||
cert_thumbprint: str | None = None
|
||||
if job.tenant_profile_id:
|
||||
profile = db.get(TenantProfile, job.tenant_profile_id)
|
||||
if profile:
|
||||
cert_private_key = profile.cert_private_key
|
||||
cert_thumbprint = profile.cert_thumbprint
|
||||
auth = AuthConfig(
|
||||
tenant_id=job.auth_tenant_id or "",
|
||||
client_id=job.auth_client_id or "",
|
||||
client_secret=job.auth_client_secret or "",
|
||||
cert_private_key=cert_private_key,
|
||||
cert_thumbprint=cert_thumbprint,
|
||||
)
|
||||
|
||||
def progress_callback(activity: str, items: int) -> None:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(ScanJob, target.job_id)
|
||||
if job:
|
||||
job.scan_activity = activity
|
||||
if items > 0:
|
||||
job.items_scanned += items
|
||||
job.heartbeat_at = datetime.utcnow()
|
||||
job.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(scan_site_for_deviations, site_url, auth, progress_callback)
|
||||
try:
|
||||
return future.result(timeout=timeout_sec)
|
||||
except FutureTimeoutError as exc:
|
||||
future.cancel()
|
||||
raise TimeoutError(f"Scan timed out after {timeout_sec}s for {site_url}") from exc
|
||||
252
docs/TECHNICAL.md
Normal file
252
docs/TECHNICAL.md
Normal file
@ -0,0 +1,252 @@
|
||||
# TECHNICAL
|
||||
|
||||
## Scope
|
||||
|
||||
Clearview scans SharePoint sites for permission deviations from the site root permission baseline.
|
||||
Designed to monitor multiple customer tenants from a single instance.
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
- Single `clearview` application container (no separate API container).
|
||||
- `postgres` service for persistent job and result storage.
|
||||
- `adminer` service for direct database inspection.
|
||||
|
||||
All services are defined in `stack/docker-compose.yml` for Portainer deployment.
|
||||
|
||||
## Application Layout
|
||||
|
||||
- `containers/clearview/site/`
|
||||
- Frontend UI (tenant management, manual URL input, CSV import, jobs, deviations)
|
||||
- `containers/clearview/src/clearview_app/`
|
||||
- FastAPI backend
|
||||
- SQLAlchemy models
|
||||
- CSV parser
|
||||
- Default-site filtering
|
||||
- Background worker for long-running scans
|
||||
|
||||
## Multi-Tenant Model
|
||||
|
||||
Clearview uses **Tenant Profiles** to manage multiple customer tenants from one instance.
|
||||
|
||||
### Tenant Profiles
|
||||
|
||||
A tenant profile stores the Azure app credentials for one customer tenant:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `name` | Label for internal reference (e.g. "Contoso") |
|
||||
| `tenant_id` | Azure Directory (tenant) ID |
|
||||
| `client_id` | Azure App (client) ID |
|
||||
| `client_secret` | Azure App client secret |
|
||||
|
||||
Profiles are managed via the **Tenants** panel in the UI or directly via the API.
|
||||
When starting a scan, you select a profile from the dropdown — no manual credential entry needed.
|
||||
|
||||
Ad-hoc scans without a saved profile are still supported via **Manual credentials** in the scan form.
|
||||
|
||||
### API Endpoints — Tenants
|
||||
|
||||
```
|
||||
GET /api/tenants List all profiles (client_secret not returned)
|
||||
POST /api/tenants Create a new profile
|
||||
DELETE /api/tenants/{id} Delete a profile (jobs are retained, tenant link is cleared)
|
||||
POST /api/tenants/{id}/generate-certificate Generate a self-signed certificate for this tenant
|
||||
```
|
||||
|
||||
### Certificate Authentication
|
||||
|
||||
Clearview supports app-only authentication via a self-signed certificate (recommended) or a client secret.
|
||||
|
||||
**Generating a certificate:**
|
||||
1. Click **Certificate** in the Tenants table.
|
||||
2. Clearview generates a self-signed RSA-2048 certificate valid for 2 years.
|
||||
3. Download the `.cer` file and upload it in Azure Portal → App registration → Certificates & secrets → Certificates.
|
||||
4. The private key is stored internally; Clearview uses it automatically when starting a scan.
|
||||
|
||||
The scanner uses the certificate path when `cert_thumbprint` is present on the tenant profile; otherwise the client secret is used.
|
||||
|
||||
`TenantProfile` authentication fields:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `client_secret` | Azure client secret (optional when a certificate is available) |
|
||||
| `cert_private_key` | PEM-encoded private key (internal, never exposed via API) |
|
||||
| `cert_thumbprint` | SHA-1 thumbprint (used by MSAL) |
|
||||
| `cert_expires_at` | Certificate expiry date |
|
||||
|
||||
## Scan Processing Model
|
||||
|
||||
Scans run asynchronously through a DB-backed job queue:
|
||||
|
||||
1. User selects a tenant profile (or enters manual credentials) and submits URLs or a CSV.
|
||||
2. API validates and normalizes URLs.
|
||||
3. Default sites are skipped by rule (tenant root and app catalog).
|
||||
4. A scan job is queued in PostgreSQL, linked to the tenant profile when applicable.
|
||||
5. Background worker processes targets with retries and per-target timeout.
|
||||
6. API/UI expose progress and deviations per job.
|
||||
|
||||
### Timeout and Retry Controls
|
||||
|
||||
Configured through environment variables (defaults shown):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SCAN_TARGET_TIMEOUT_SEC` | `3600` | Max seconds per target before it is marked failed |
|
||||
| `SCAN_TARGET_MAX_RETRIES` | `2` | Number of retries on transient failure |
|
||||
| `SCAN_RETRY_BASE_DELAY_SEC` | `2` | Base delay for exponential back-off between retries |
|
||||
| `SCAN_JOB_POLL_INTERVAL_SEC` | `3` | How often the worker polls for new queued jobs |
|
||||
| `SCAN_HTTP_TIMEOUT_SEC` | `30` | Per-request HTTP timeout toward SharePoint |
|
||||
| `SCAN_HTTP_MAX_RETRIES` | `3` | Retries on HTTP 429/503 or connection errors |
|
||||
| `SCAN_LIST_PAGE_SIZE` | `200` | Items per page when listing library contents |
|
||||
| `SCAN_MAX_ITEMS_PER_LIST` | `10000` | Cap on items with unique permissions per library |
|
||||
|
||||
## Deviation Detection
|
||||
|
||||
The scanner retrieves SharePoint REST role assignments at four levels:
|
||||
|
||||
- Site root
|
||||
- Document library
|
||||
- Folder
|
||||
- File
|
||||
|
||||
Only permissions **added** relative to the site root are stored as deviations (`delta_type=added`).
|
||||
No filesystem/NTFS permission model is used.
|
||||
|
||||
### Hierarchical Deduplication
|
||||
|
||||
After all deviations for a target are collected they are post-processed: if a `(principal, role)` deviation is already reported at a parent URL (library or folder), the same deviation on child items is suppressed. This prevents an explosion of results when a single folder grant propagates to thousands of files.
|
||||
|
||||
Deduplication is pure in-memory post-processing — no additional API calls are made.
|
||||
|
||||
### Role Name Normalisation
|
||||
|
||||
SharePoint returns role names in the language configured for the tenant. Clearview normalises common Dutch role names to their English equivalents before storing them:
|
||||
|
||||
| Dutch | English |
|
||||
|---|---|
|
||||
| Volledig beheer | Full Control |
|
||||
| Bijdragen | Contribute |
|
||||
| Lezen | Read |
|
||||
| Bewerken | Edit |
|
||||
| Ontwerpen | Design |
|
||||
| Beperkte toegang | Limited Access |
|
||||
| Goedkeuren | Approve |
|
||||
| Hiërarchieën beheren | Manage Hierarchy |
|
||||
| Weergeven alleen | View Only |
|
||||
| Beperkt lezen | Restricted Read |
|
||||
|
||||
Unknown role names are stored as-is.
|
||||
|
||||
### SharingLinks
|
||||
|
||||
SharePoint creates internal groups named `SharingLinks.{guid}.{LinkType}.{guid}` whenever a user shares a file or folder via a sharing link. Clearview detects these and classifies them by risk:
|
||||
|
||||
| Link type | Risk | UI colour |
|
||||
|---|---|---|
|
||||
| `Anonymous*` | Critical | Red |
|
||||
| `Flexible` | High | Orange |
|
||||
| `Organization*` | Low | Blue |
|
||||
| `Direct*` | Low | Green |
|
||||
|
||||
**Resolve Sharing Links** — after a scan completes, the Job Details panel shows a _Resolve Sharing Links_ section listing all SharingLinks types found in the job. The user selects which types to resolve and clicks **Resolve**. Clearview calls `/_api/web/sitegroups/getbyname('{name}')/users` for each unique group using the job's stored credentials and writes the member list to `permission_deviations.resolved_members`. Anonymous links have no resolvable members; their `resolved_members` field is stored as an empty string, displayed as `(public link)` in the UI.
|
||||
|
||||
Anonymous and Flexible types are pre-selected by default. Organization and Direct types are available but unchecked by default.
|
||||
|
||||
### API Endpoints — Scan Jobs
|
||||
|
||||
```
|
||||
GET /api/scan-jobs List jobs (optional ?tenant_profile_id=)
|
||||
POST /api/scan-jobs Create job from URLs
|
||||
POST /api/scan-jobs/import-csv Create job from CSV upload
|
||||
GET /api/scan-jobs/{id} Get job detail (targets + deviations)
|
||||
POST /api/scan-jobs/{id}/cancel Cancel a queued or running job
|
||||
DELETE /api/scan-jobs/{id} Delete a completed job and all its data
|
||||
POST /api/scan-jobs/{id}/resolve-sharing-links Resolve SharingLinks group members post-scan
|
||||
GET /api/scan-jobs/{id}/export Download deviations as .xlsx (optional ?site_url=)
|
||||
```
|
||||
|
||||
## Job Details UI
|
||||
|
||||
The **Selected Job Details** panel provides:
|
||||
|
||||
- **Site filter** — dropdown populated from the job's targets; filters both the Targets and Deviations tables client-side without a new API call.
|
||||
- **Export Excel** — downloads a `.xlsx` with two sheets:
|
||||
- _Targets_: URL, status, attempts, error, timestamps
|
||||
- _Deviations_: Site URL, Object URL (relative to site), Object Type, Principal, Link Risk (colour-coded), Resolved Members, Role, Delta — sorted by Site URL → Object URL → Principal
|
||||
- **Resolve Sharing Links** — see SharingLinks section above.
|
||||
|
||||
## CSV Import
|
||||
|
||||
Expected input is Microsoft Sites export format.
|
||||
|
||||
- URL column is auto-detected (`URL` / `Site URL` / `SiteUrl`).
|
||||
- UTF-8 BOM is supported.
|
||||
- Duplicate URLs are de-duplicated.
|
||||
|
||||
## Data Model
|
||||
|
||||
Main tables:
|
||||
|
||||
| Table | Key columns |
|
||||
|---|---|
|
||||
| `tenant_profiles` | credentials, `cert_private_key`, `cert_thumbprint`, `cert_expires_at` |
|
||||
| `scan_jobs` | `status`, `tenant_profile_id`, progress counters, auth credentials |
|
||||
| `scan_targets` | `job_id`, `site_url`, `status`, `attempts`, `error_message` |
|
||||
| `permission_deviations` | `job_id`, `site_url`, `object_url`, `object_type`, `principal`, `role_name`, `delta_type`, `resolved_members` |
|
||||
|
||||
Scan jobs, targets, and deviations are cascade-deleted when a job is removed via `DELETE /api/scan-jobs/{id}`. Jobs with status `queued` or `running` cannot be deleted.
|
||||
|
||||
Schema migrations for new columns are applied automatically on startup via `_ensure_schema_columns()` in `main.py`.
|
||||
|
||||
## Build and Release
|
||||
|
||||
Use `./build-and-push.sh` from repo root.
|
||||
|
||||
- `./build-and-push.sh t` for test build (`:dev` tag only)
|
||||
- `./build-and-push.sh 1` patch release
|
||||
- `./build-and-push.sh 2` minor release
|
||||
- `./build-and-push.sh 3` major release
|
||||
|
||||
## Current Scan Mode
|
||||
|
||||
`SHAREPOINT_SCAN_MODE=sharepoint_app_only` is active by default.
|
||||
|
||||
Azure app-only credentials are resolved per scan job from the linked tenant profile,
|
||||
or from the raw credentials submitted with the job when no profile is used.
|
||||
|
||||
### Entra App Registration — two modes
|
||||
|
||||
The UI automatically detects which mode is active via `GET /api/onboarding/status`.
|
||||
The onboarding flow is accessed from the **Add Tenant** form in the Tenants panel.
|
||||
|
||||
#### Mode A — Automated (platform app configured)
|
||||
|
||||
Requires a pre-registered Clearview platform app in Azure AD with permission to create
|
||||
apps in customer tenants (`Application.ReadWrite.All` on Microsoft Graph).
|
||||
|
||||
Set the following in `stack/.env`:
|
||||
|
||||
```
|
||||
ONBOARDING_CLIENT_ID=<platform-app-client-id>
|
||||
ONBOARDING_CLIENT_SECRET=<platform-app-client-secret>
|
||||
ONBOARDING_REDIRECT_URI=https://<your-clearview-domain>/api/onboarding/microsoft/callback
|
||||
```
|
||||
|
||||
Flow per customer tenant:
|
||||
1. Click **Add Tenant** in the UI and then **Connect Microsoft**.
|
||||
2. Approve admin consent in the customer's Microsoft tenant.
|
||||
3. UI receives tenant context from the OAuth callback and pre-fills the tenant ID.
|
||||
4. Click **Create Scan App Automatically** to create a tenant-local scan app via Graph API.
|
||||
5. Clearview assigns SharePoint `Sites.FullControl.All` and generates a client secret.
|
||||
6. Enter a name and click **Save Tenant** to store the profile.
|
||||
|
||||
#### Mode B — Manual (no platform app configured)
|
||||
|
||||
When `ONBOARDING_*` env vars are empty the UI shows step-by-step instructions to create
|
||||
the scan app manually per customer tenant:
|
||||
|
||||
1. Azure Portal → Entra ID → App registrations → New registration (Single tenant).
|
||||
2. Copy Directory (tenant) ID and Application (client) ID from the Overview page.
|
||||
3. API permissions → Add → SharePoint → Application permissions → `Sites.FullControl.All` → Grant admin consent.
|
||||
4. Certificates & secrets → New client secret → copy the value (shown once).
|
||||
5. Enter the details in **Add Tenant** and click **Save Tenant**.
|
||||
127
docs/changelog-develop.md
Normal file
127
docs/changelog-develop.md
Normal file
@ -0,0 +1,127 @@
|
||||
# Changelog - Develop
|
||||
|
||||
This file documents changes on the develop branch of this project.
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### Added
|
||||
- **Site filter in Job Details** — dropdown in the Selected Job Details panel to filter Targets and Deviations tables by site URL (client-side, no extra API call).
|
||||
- **Excel export** — `GET /api/scan-jobs/{id}/export` endpoint (optional `?site_url=` filter) returns a `.xlsx` file with two sheets:
|
||||
- _Targets_: URL, status, attempts, error, timestamps.
|
||||
- _Deviations_: Site URL, relative Object URL, Object Type, Principal, Link Risk (colour-coded), Resolved Members, Role, Delta — sorted by Site URL → Object URL → Principal.
|
||||
- **Hierarchical deduplication** — after scanning a target, deviations are post-processed to suppress child-level entries already covered by a parent (library/folder). Prevents result explosion on large sites with deeply inherited permissions. No additional API calls.
|
||||
- **SharingLinks classification and colour coding** — SharePoint sharing-link principals are parsed and displayed with a risk badge in the Deviations table:
|
||||
- `Anonymous*` → Critical (red)
|
||||
- `Flexible` → High (orange)
|
||||
- `Organization*` → Low (blue)
|
||||
- `Direct*` → Low (green)
|
||||
- **Resolve Sharing Links** — post-scan action in the Job Details panel. Fetches the actual member list of sharing-link groups via `/_api/web/sitegroups/getbyname/users`. Stored in new `permission_deviations.resolved_members` column. Anonymous links produce an empty member list (shown as `(public link)`). New endpoint: `POST /api/scan-jobs/{id}/resolve-sharing-links`.
|
||||
- **Role name normalisation** — common Dutch SharePoint role names (e.g. "Volledig beheer", "Bijdragen") are translated to their English equivalents at scan time before being stored.
|
||||
- **`openpyxl` dependency** added to `requirements.txt`.
|
||||
- **Favicon** replaced with a dedicated icon (blue rounded square with eye/keyhole symbol) instead of the concept design SVG.
|
||||
|
||||
### Changed
|
||||
- `SCAN_TARGET_TIMEOUT_SEC` default raised from 180 s to 3600 s (1 hour) to accommodate large sites with tens of thousands of files.
|
||||
- `permission_deviations` table extended with `resolved_members TEXT` column (auto-migrated on startup).
|
||||
- Object URL in the Deviations table and Excel export is now shown relative to the site URL (site URL prefix stripped).
|
||||
- Principal display in the Deviations table strips the SharePoint claim prefix (e.g. `i:0#.f|membership|`) and shows only the email/name; full value visible on hover.
|
||||
- Site URL in the Deviations table is abbreviated to the last path segment with full URL on hover.
|
||||
- Deviations table uses `table-layout: fixed` with column widths sized to fit on a 1080p display.
|
||||
- `docs/TECHNICAL.md` and `README.md` updated to reflect all new functionality.
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### Added
|
||||
- Certificate-based authentication for SharePoint app-only access:
|
||||
- Clearview generates a self-signed RSA-2048 certificate per tenant (no external CA required).
|
||||
- New endpoint `POST /api/tenants/{id}/generate-certificate` stores the private key and returns the public cert.
|
||||
- Public certificate downloadable as a `.cer` file from the UI, named after the tenant.
|
||||
- Scanner uses MSAL with certificate when available; client secret remains as fallback.
|
||||
- Resolves SharePoint error "Unsupported app only token" when using client secret authentication.
|
||||
- `TenantProfile` extended with `cert_private_key`, `cert_thumbprint`, and `cert_expires_at`.
|
||||
- Tenant table shows auth method (cert with expiry date or secret).
|
||||
- Client secret is now optional when creating a tenant profile (can be omitted when a certificate will be used).
|
||||
- Job deletion: `DELETE /api/scan-jobs/{id}` endpoint added (not allowed for queued or running jobs).
|
||||
- Delete button per job in the UI; cascades to targets and deviations.
|
||||
|
||||
### Fixed
|
||||
- SharePoint REST API error when fetching list items: removed `$filter=HasUniqueRoleAssignments eq true` as SharePoint does not support this field as an OData filter. The check is now performed client-side.
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### Added
|
||||
- Multi-tenant support: Clearview now manages multiple customer tenants from a single instance.
|
||||
- New `TenantProfile` data model (`tenant_profiles` table) for storing customer credentials.
|
||||
- `ScanJob` linked to a tenant profile via `tenant_profile_id` FK.
|
||||
- API endpoints for tenant profile management: `GET/POST /api/tenants`, `DELETE /api/tenants/{id}`.
|
||||
- `GET /api/scan-jobs` supports filtering by `tenant_profile_id`.
|
||||
- UI fully redesigned for multi-tenant use:
|
||||
- New **Tenants** panel with a table of configured customers, Add/Delete actions, and a Scan shortcut per tenant.
|
||||
- Onboarding flow (Connect Microsoft / manual instructions) moved into the Add Tenant form.
|
||||
- Scan form uses a tenant profile dropdown; manual credentials only shown as a fallback option.
|
||||
- Jobs table extended with a **Tenant** column and a tenant filter dropdown.
|
||||
- Hero stats now show: Tenants / Jobs / Active Jobs.
|
||||
- XSS escaping added for all user-supplied data rendered in the jobs and deviations tables.
|
||||
|
||||
### Changed
|
||||
- `TECHNICAL.md` updated with multi-tenant model documentation, tenant profile API, and redesigned onboarding flow.
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### Added
|
||||
- Initial repository structure.
|
||||
- `containers/` directory added with the `clearview` starter service.
|
||||
- `build-and-push.sh` added for container build and push.
|
||||
- `docs/TECHNICAL.md` added.
|
||||
- `docs/changelog-develop.md` added.
|
||||
- `version.txt` added with initial version.
|
||||
- `.last-branch` added for branch tracking in the build script.
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### Added
|
||||
- FastAPI backend integrated into the `clearview` container (single-container app runtime).
|
||||
- PostgreSQL-backed scan job model (`scan_jobs`, `scan_targets`, `permission_deviations`).
|
||||
- Background scan worker with queue processing, retries, and per-target timeout controls.
|
||||
- API endpoints for manual URL scan creation, CSV import, job listing, and job detail retrieval.
|
||||
- CSV parsing support for Microsoft Sites export format with URL normalization and de-duplication.
|
||||
- Default-site skip rules for tenant root and app catalog paths.
|
||||
- Frontend replaced with production-oriented scan UI:
|
||||
- Manual URL submission
|
||||
- CSV upload
|
||||
- Job status overview
|
||||
- Target-level result view
|
||||
- Deviation table view
|
||||
- Stack configuration extended with scan worker runtime environment settings.
|
||||
|
||||
### Changed
|
||||
- `containers/clearview/Dockerfile` switched from static nginx hosting to Python FastAPI runtime.
|
||||
|
||||
### Added
|
||||
- Real SharePoint scan implementation for app-only authentication mode (`SHAREPOINT_SCAN_MODE=sharepoint_app_only`):
|
||||
- OAuth2 client credentials token acquisition via Microsoft Entra ID.
|
||||
- Site root permission baseline loading through SharePoint REST `roleassignments`.
|
||||
- Document library, folder, and file traversal with unique-permission detection (`HasUniqueRoleAssignments`).
|
||||
- Deviation persistence only for rights not present on site root (`delta_type=added`).
|
||||
- HTTP retry/backoff and throttle handling (429/503), plus list-level scan caps.
|
||||
- Scanner HTTP retry/backoff and list-limit controls added in backend configuration.
|
||||
|
||||
### Changed
|
||||
- Authentication flow updated to universal multi-tenant style:
|
||||
- Azure credentials are now supplied per scan job from the web UI/API payload.
|
||||
- No Azure tenant/client/secret dependency in stack `.env`.
|
||||
- Added UI and technical documentation guidance for one-time Entra app setup and required SharePoint permission (`Sites.FullControl.All` + admin consent).
|
||||
|
||||
### Added
|
||||
- Automated onboarding endpoint `POST /api/onboarding/create-scan-app`:
|
||||
- Creates a dedicated scan app in Entra for the connected tenant.
|
||||
- Configures SharePoint app permission `Sites.FullControl.All`.
|
||||
- Creates service principal and assigns app role consent.
|
||||
- Generates and returns a new client secret.
|
||||
- Microsoft connect/admin-consent flow endpoints:
|
||||
- `GET /api/onboarding/microsoft/connect-url`
|
||||
- `GET /api/onboarding/microsoft/callback`
|
||||
- UI onboarding flow updated:
|
||||
- `Connect Microsoft` button for admin consent redirect
|
||||
- Callback handling to capture tenant id
|
||||
- Automatic scan-app creation without manual bootstrap app input
|
||||
18
stack/.env
Normal file
18
stack/.env
Normal file
@ -0,0 +1,18 @@
|
||||
CLEARVIEW_IMAGE=gitea.oskamp.info/ivooskamp/clearview:dev
|
||||
CLEARVIEW_PORT=8080
|
||||
TZ=Europe/Amsterdam
|
||||
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=clearview
|
||||
POSTGRES_USER=clearview
|
||||
POSTGRES_PASSWORD=clearview
|
||||
|
||||
ADMINER_IMAGE=adminer:4-standalone
|
||||
ADMINER_PORT=8081
|
||||
|
||||
DATABASE_URL=postgresql://clearview:clearview@postgres:5432/clearview
|
||||
ONBOARDING_CLIENT_ID=
|
||||
ONBOARDING_CLIENT_SECRET=
|
||||
ONBOARDING_REDIRECT_URI=
|
||||
46
stack/docker-compose.yml
Normal file
46
stack/docker-compose.yml
Normal file
@ -0,0 +1,46 @@
|
||||
services:
|
||||
clearview:
|
||||
image: ${CLEARVIEW_IMAGE:-gitea.oskamp.info/ivooskamp/clearview:dev}
|
||||
container_name: clearview
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLEARVIEW_PORT:-8080}:80"
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Amsterdam}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-clearview}:${POSTGRES_PASSWORD:-clearview}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-clearview}}
|
||||
ONBOARDING_CLIENT_ID: ${ONBOARDING_CLIENT_ID:-}
|
||||
ONBOARDING_CLIENT_SECRET: ${ONBOARDING_CLIENT_SECRET:-}
|
||||
ONBOARDING_REDIRECT_URI: ${ONBOARDING_REDIRECT_URI:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
container_name: clearview-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-clearview}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-clearview}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clearview}
|
||||
TZ: ${TZ:-Europe/Amsterdam}
|
||||
volumes:
|
||||
- /docker/appdata/clearview/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-clearview} -d ${POSTGRES_DB:-clearview}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
adminer:
|
||||
image: ${ADMINER_IMAGE:-adminer:4-standalone}
|
||||
container_name: clearview-adminer
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${ADMINER_PORT:-8081}:8080"
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: ${POSTGRES_HOST:-postgres}
|
||||
1
version.txt
Normal file
1
version.txt
Normal file
@ -0,0 +1 @@
|
||||
v0.1.0
|
||||
Loading…
Reference in New Issue
Block a user