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:
Ivo Oskamp 2026-04-13 16:50:41 +02:00
commit b8446c0665
28 changed files with 4933 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.files
.last-branch
__pycache__/
*.pyc
*.pyo
.codex

97
README.md Normal file
View 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
View 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 "============================================================"

View 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"]

View 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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// -------------------------------------------------------------------------
// 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();
})();

View 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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 &amp; 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, 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

View 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

View 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

View 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 &rarr; App registrations &rarr; 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 &rarr; Add &rarr; SharePoint &rarr; Application permissions</strong>, add <code>Sites.FullControl.All</code>.</li>
<li>Click <strong>Grant admin consent</strong>.</li>
<li>Go to <strong>Certificates &amp; secrets &rarr; 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 &rarr; App registrations &rarr; [your app] &rarr; Certificates &amp; secrets &rarr; Certificates &rarr; 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&#10;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>

View 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;
}
}

View File

@ -0,0 +1 @@
"""Clearview application package."""

View 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,
)

View 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)

View 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

View 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)

View 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

View 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))

View 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")

View 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

View 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)

View 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

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
v0.1.0