mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(docs): Two-Doors home, otter-orange brand, Pagefind search, and enterprise SSO/SCIM/roles guides
Docs site redesign: Two-Doors home, otter-orange brand, Pagefind search, enterprise SSO/SCIM/roles guides.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import llmstxt from "vitepress-plugin-llms";
|
||||
import { pagefindPlugin } from "vitepress-plugin-pagefind";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -15,7 +16,8 @@ export default defineConfig({
|
||||
sitemap: { hostname: "https://docs.snapotter.com" },
|
||||
|
||||
head: [
|
||||
["meta", { name: "theme-color", content: "#3b82f6" }],
|
||||
["meta", { name: "theme-color", content: "#E07832" }],
|
||||
["link", { rel: "preload", href: "/fonts/bricolage-grotesque-var.woff2", as: "font", type: "font/woff2", crossorigin: "" }],
|
||||
["link", { rel: "icon", type: "image/png", sizes: "48x48", href: "/favicon.png" }],
|
||||
["link", { rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
|
||||
["link", { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" }],
|
||||
@@ -47,6 +49,7 @@ export default defineConfig({
|
||||
|
||||
vite: {
|
||||
plugins: [
|
||||
pagefindPlugin(),
|
||||
llmstxt({
|
||||
domain: "https://docs.snapotter.com",
|
||||
customLLMsTxtTemplate: `# {title}
|
||||
@@ -107,6 +110,9 @@ export default defineConfig({
|
||||
{ text: "Architecture", link: "/guide/architecture" },
|
||||
{ text: "Configuration", link: "/guide/configuration" },
|
||||
{ text: "OIDC / SSO", link: "/guide/oidc" },
|
||||
{ text: "SAML SSO", link: "/guide/saml" },
|
||||
{ text: "SCIM Provisioning", link: "/guide/scim" },
|
||||
{ text: "Users, Roles & Permissions", link: "/guide/users-roles" },
|
||||
{ text: "Database", link: "/guide/database" },
|
||||
{ text: "Deployment", link: "/guide/deployment" },
|
||||
{ text: "Security & Hardening", link: "/guide/security" },
|
||||
@@ -360,10 +366,6 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
footer: {
|
||||
message:
|
||||
'Released under the <a href="https://github.com/snapotter-hq/snapotter/blob/main/LICENSE">AGPLv3 License</a>.',
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
const command = "docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest";
|
||||
|
||||
const selfLinks = [
|
||||
{ label: "Quick start", href: "/guide/getting-started#quick-start" },
|
||||
{ label: "Configuration", href: "/guide/configuration" },
|
||||
{ label: "Hardware & sizing", href: "/guide/deployment#hardware-requirements" },
|
||||
{ label: "Database & backups", href: "/guide/database" },
|
||||
{ label: "Docker tags & GPU", href: "/guide/docker-tags" },
|
||||
{ label: "Supported formats", href: "/guide/supported-formats" },
|
||||
];
|
||||
const entLinks = [
|
||||
{ label: "Architecture", href: "/guide/architecture" },
|
||||
{ label: "Security & hardening", href: "/guide/security" },
|
||||
{ label: "SSO · SAML · OIDC", href: "/guide/oidc" },
|
||||
{ label: "SCIM provisioning", href: "/guide/scim" },
|
||||
{ label: "Users, roles & audit", href: "/guide/users-roles" },
|
||||
{ label: "Compliance & SBOM", href: "/guide/security#compliance-artifacts" },
|
||||
];
|
||||
const modalities = [
|
||||
{ label: "Image", count: 64, href: "/tools/resize" },
|
||||
{ label: "Video", count: 29, href: "/tools/convert-video" },
|
||||
{ label: "Audio", count: 17, href: "/tools/convert-audio" },
|
||||
{ label: "PDF", count: 37, href: "/tools/merge-pdf" },
|
||||
{ label: "Data", count: 10, href: "/tools/chart-maker" },
|
||||
];
|
||||
const shared = [
|
||||
{ label: "REST API", sub: "Keys, endpoints & OpenAPI", href: "/api/rest" },
|
||||
{ label: "Changelog", sub: "What's new in 2.0", href: "/changelog" },
|
||||
{ label: "llms.txt", sub: "AI-friendly docs", href: "/llms.txt" },
|
||||
];
|
||||
|
||||
import { ref } from "vue";
|
||||
const copyLabel = ref("Copy");
|
||||
function copyCommand() {
|
||||
navigator.clipboard?.writeText(command);
|
||||
copyLabel.value = "Copied!";
|
||||
setTimeout(() => { copyLabel.value = "Copy"; }, 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="so-home">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Self-hosted · Open source · AGPLv3</p>
|
||||
<h1 class="hero-title">SnapOtter Documentation</h1>
|
||||
<p class="hero-sub">
|
||||
<strong>157 tools</strong> for image, video, audio, PDF & data, running entirely on your
|
||||
hardware. Choose your path below, or get running in one command:
|
||||
</p>
|
||||
<div class="cmd">
|
||||
<code>$ {{ command }}</code>
|
||||
<button class="copy" type="button" aria-label="Copy command" @click="copyCommand">{{ copyLabel }}</button>
|
||||
</div>
|
||||
<p class="hero-meta">
|
||||
<a href="/guide/getting-started">Full install guide</a> ·
|
||||
<a href="/guide/getting-started#docker-compose">GPU & Compose setup</a> ·
|
||||
<a href="https://demo.snapotter.com">Try the live demo</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="doors">
|
||||
<div class="door self">
|
||||
<div class="door-head">
|
||||
<span class="door-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/></svg>
|
||||
</span>
|
||||
<h2>Self-hosting</h2>
|
||||
</div>
|
||||
<p class="door-sub">Get SnapOtter running and keep it healthy.</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in selfLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="/guide/getting-started">Start self-hosting →</a>
|
||||
</div>
|
||||
|
||||
<div class="door ent">
|
||||
<div class="door-head">
|
||||
<span class="door-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V7l8-4v18M19 21V11l-6-4"/></svg>
|
||||
</span>
|
||||
<h2>Enterprise</h2>
|
||||
</div>
|
||||
<p class="door-sub">Evaluate, secure & govern your deployment.</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in entLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="/guide/architecture">Evaluate for your org →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mod">
|
||||
<p class="mod-head"><strong>157 tools across 5 modalities</strong> <span>browse the full reference by type</span></p>
|
||||
<div class="chips">
|
||||
<a v-for="m in modalities" :key="m.href" class="chip" :href="m.href">
|
||||
<span class="chip-label">{{ m.label }}</span>
|
||||
<span class="chip-count">{{ m.count }} tools</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shared">
|
||||
<a v-for="s in shared" :key="s.href" class="scard" :href="s.href">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span>{{ s.sub }}</span>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.so-home { max-width: 1080px; margin: 0 auto; padding: 16px 24px 64px; }
|
||||
.hero { text-align: center; padding: 48px 16px 28px; }
|
||||
.eyebrow { font: 600 11px/1 var(--vp-font-family-mono); letter-spacing: .16em; text-transform: uppercase; color: #A85518; margin-bottom: 14px; }
|
||||
.hero-title { font-family: var(--so-font-heading); font-size: 42px; font-weight: 800; letter-spacing: -.03em; margin-bottom: 12px; }
|
||||
.hero-sub { color: var(--vp-c-text-2); font-size: 17px; max-width: 60ch; margin: 0 auto 20px; }
|
||||
.cmd { display: flex; gap: 14px; align-items: center; max-width: 680px; margin: 0 auto; background: #15100B; border: 1px solid #3A2A1E; border-radius: 10px; padding: 13px 16px; text-align: left; }
|
||||
.cmd code { flex: 1; min-width: 0; font: 600 13.5px/1.5 var(--vp-font-family-mono); color: #FFD9B0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.copy { flex: none; font: 600 10.5px/1 var(--vp-font-family-mono); color: #cbb9a6; border: 1px solid #4a3a2c; border-radius: 6px; padding: 8px 11px; background: transparent; cursor: pointer; }
|
||||
.hero-meta { margin-top: 12px; font-size: 13px; color: var(--vp-c-text-2); }
|
||||
.hero-meta a { color: #A85518; font-weight: 600; }
|
||||
.doors { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.door { border: 1px solid var(--vp-c-border); border-radius: 16px; padding: 24px; transition: transform .15s, box-shadow .15s; }
|
||||
.door:hover { transform: translateY(-3px); box-shadow: 0 22px 44px -26px rgba(20,12,4,.5); }
|
||||
.door.self { background: linear-gradient(180deg, #FFF5ED, #fff); }
|
||||
.door.ent { background: linear-gradient(165deg, #241A13, #15100B); color: #F0EBE4; border-color: #3A2A1E; }
|
||||
.door-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.door-icon { width: 40px; height: 40px; border-radius: 11px; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.door.self .door-icon { background: #E07832; color: #fff; }
|
||||
.door.ent .door-icon { background: #3A2A1E; color: #F09550; }
|
||||
.door-head h2 { font-family: var(--so-font-heading); font-size: 22px; font-weight: 700; border: 0; padding: 0; margin: 0; }
|
||||
.door-sub { font-size: 13.5px; margin-bottom: 18px; }
|
||||
.door.self .door-sub { color: var(--vp-c-text-2); }
|
||||
.door.ent .door-sub { color: #C9BCAE; }
|
||||
.door-links { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; list-style: none; margin: 0 0 20px; padding: 0; }
|
||||
.door-links li { border-bottom: 1px solid; padding: 0; }
|
||||
.door.self .door-links li { border-color: #EBDDCC; }
|
||||
.door.ent .door-links li { border-color: #3A2C20; }
|
||||
.door-links a { display: block; padding: 10px 2px; font-size: 13.5px; font-weight: 500; text-decoration: none; color: inherit; }
|
||||
.door.self .door-links a:hover { color: #A85518; }
|
||||
.door.ent .door-links a:hover { color: #F09550; }
|
||||
.door-cta { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13.5px; padding: 11px 18px; border-radius: 10px; text-decoration: none; }
|
||||
.door.self .door-cta { background: #A85518; color: #fff; }
|
||||
.door.ent .door-cta { background: #F09550; color: #231A12; }
|
||||
.mod { padding: 28px 0 6px; }
|
||||
.mod-head { margin-bottom: 13px; }
|
||||
.mod-head strong { font-family: var(--so-font-heading); font-size: 15px; }
|
||||
.mod-head span { font-size: 12.5px; color: var(--vp-c-text-2); margin-left: 8px; }
|
||||
.chips { display: grid; grid-template-columns: repeat(5, 1fr); gap: 11px; }
|
||||
.chip { display: flex; flex-direction: column; gap: 2px; border: 1px solid var(--vp-c-border); border-radius: 11px; padding: 12px 13px; text-decoration: none; transition: border-color .15s, transform .15s; }
|
||||
.chip:hover { border-color: #E07832; transform: translateY(-2px); }
|
||||
.chip-label { font-size: 12.5px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.chip-count { font: 700 12px/1 var(--vp-font-family-mono); color: #A85518; }
|
||||
.shared { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; padding: 18px 0 0; }
|
||||
.scard { display: flex; flex-direction: column; gap: 2px; border: 1px solid var(--vp-c-border); border-radius: 12px; padding: 14px 15px; text-decoration: none; transition: border-color .15s, transform .15s; }
|
||||
.scard:hover { border-color: #E07832; transform: translateY(-2px); }
|
||||
.scard strong { font-family: var(--so-font-heading); font-size: 13.5px; color: var(--vp-c-text-1); }
|
||||
.scard span { font-size: 11.5px; color: var(--vp-c-text-2); }
|
||||
|
||||
:root.dark .chip-label, :root.dark .scard strong { color: var(--vp-c-text-1); }
|
||||
:root.dark .door.self { background: linear-gradient(180deg, #2A1F16, #221A13); border-color: #3A2C20; color: #F0EBE4; }
|
||||
:root.dark .door.self .door-links li { border-color: #3A2C20; }
|
||||
:root.dark .chip-count { color: #F0A766; }
|
||||
:root.dark .hero-meta a, :root.dark .eyebrow { color: #F0A766; }
|
||||
:root.dark .door.self .door-sub { color: #C9BCAE; }
|
||||
:root.dark .door.self .door-links a:hover { color: #F09550; }
|
||||
:root.dark .door.self .door-cta { background: #A85518; }
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.doors, .shared { grid-template-columns: 1fr; }
|
||||
.chips { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
@font-face {
|
||||
font-family: "Bricolage Grotesque";
|
||||
src: url("/fonts/bricolage-grotesque-var.woff2") format("woff2");
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--so-font-heading: "Bricolage Grotesque", var(--vp-font-family-base);
|
||||
}
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3,
|
||||
.vp-doc h4 {
|
||||
font-family: var(--so-font-heading);
|
||||
}
|
||||
@@ -1,34 +1,3 @@
|
||||
/* Move the docker command banner between hero and features */
|
||||
.VPHome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.VPHome > .VPHomeHero {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.VPHome > .vp-doc {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.VPHome > .VPHomeFeatures {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* Quick start command banner on homepage */
|
||||
.quick-start-banner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 48px;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-start-banner div[class*="language-"] {
|
||||
margin: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Hide the three-dots extra menu - its contents are now in the navbar directly */
|
||||
.VPNavBarExtra {
|
||||
display: none !important;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import DocsHome from "./DocsHome.vue";
|
||||
import Layout from "./Layout.vue";
|
||||
import "./vars.css";
|
||||
import "./fonts.css";
|
||||
import "./github-stars.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app }) {
|
||||
app.component("DocsHome", DocsHome);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Otter-orange brand override. AA note: #A85518 on white = ~5.2:1 (passes AA for text + buttons).
|
||||
Vibrant #E07832 / #F09550 are decorative only (see DocsHome.vue), never under white text. */
|
||||
:root {
|
||||
--vp-c-brand-1: #A85518;
|
||||
--vp-c-brand-2: #8F4712;
|
||||
--vp-c-brand-3: #A85518;
|
||||
--vp-c-brand-soft: rgba(224, 120, 50, 0.14);
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: #fff;
|
||||
--vp-button-brand-bg: #A85518;
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: #fff;
|
||||
--vp-button-brand-hover-bg: #8F4712;
|
||||
--vp-button-brand-active-border: transparent;
|
||||
--vp-button-brand-active-text: #fff;
|
||||
--vp-button-brand-active-bg: #7A3D10;
|
||||
}
|
||||
.dark {
|
||||
--vp-c-brand-1: #F0A766;
|
||||
--vp-c-brand-2: #F09550;
|
||||
--vp-c-brand-3: #A85518;
|
||||
--vp-c-brand-soft: rgba(240, 149, 80, 0.16);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ description: Set up Single Sign-On with OpenID Connect. Step-by-step guides for
|
||||
|
||||
SnapOtter supports OpenID Connect (OIDC) for single sign-on. Users can log in with an external identity provider such as Keycloak, Authentik, or Google instead of (or alongside) local username/password authentication.
|
||||
|
||||
::: tip See also
|
||||
[SAML SSO](/guide/saml) | [SCIM Provisioning](/guide/scim) | [Users, Roles & Permissions](/guide/users-roles)
|
||||
:::
|
||||
|
||||
## Quick start
|
||||
|
||||
Add these environment variables to your `docker-compose.yml`:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
description: Set up SAML 2.0 Single Sign-On for SnapOtter. Step-by-step guides for Okta, Azure AD / Entra ID, Google Workspace, and other SAML identity providers.
|
||||
---
|
||||
|
||||
# SAML SSO
|
||||
|
||||
SnapOtter supports SAML 2.0 for single sign-on. Users can log in via an external identity provider (Okta, Azure AD / Entra ID, Google Workspace, or any standard SAML 2.0 IdP) instead of local username/password authentication.
|
||||
|
||||
::: tip Enterprise feature
|
||||
SAML SSO requires a **team** or **enterprise** license with the `saml_sso` feature. If `SAML_ENABLED=true` is set without a valid license, the SAML routes are silently skipped and a warning is logged.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running SnapOtter instance reachable at a public URL
|
||||
- `EXTERNAL_URL` set to that public URL (e.g. `https://photos.example.com`)
|
||||
- A team or enterprise license key with the `saml_sso` feature
|
||||
- Admin access to your SAML identity provider
|
||||
|
||||
## Quick start
|
||||
|
||||
Add these environment variables to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
snapotter:
|
||||
image: snapotter/snapotter:latest
|
||||
environment:
|
||||
EXTERNAL_URL: "https://photos.example.com"
|
||||
SNAPOTTER_LICENSE_KEY: "your-license-key"
|
||||
SAML_ENABLED: "true"
|
||||
SAML_IDP_SSO_URL: "https://idp.example.com/sso/saml"
|
||||
SAML_IDP_CERTIFICATE: |
|
||||
MIICpDCCAYwCCQDU+pQ4pHgSpDANBgkqhkiG9w0BAQsFADAUMRIw
|
||||
...your IdP's signing certificate in PEM format...
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAE
|
||||
```
|
||||
|
||||
Restart the container. A "Sign in with SAML" button (or the label set by `SAML_PROVIDER_NAME`) appears on the login page.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SAML_ENABLED` | `false` | Enable SAML login. |
|
||||
| `SAML_IDP_SSO_URL` | | IdP's SSO endpoint URL. **Required** when SAML is enabled. |
|
||||
| `SAML_IDP_CERTIFICATE` | | IdP's X.509 signing certificate in PEM format (the certificate text itself, not a file path). **Required** when SAML is enabled. |
|
||||
| `EXTERNAL_URL` | | The public URL where SnapOtter is reachable. **Required** when SAML is enabled. |
|
||||
| `SAML_ENTITY_ID` | `${EXTERNAL_URL}/api/auth/saml/metadata` | SP Entity ID / Audience URI sent to the IdP. |
|
||||
| `SAML_CALLBACK_URL` | `${EXTERNAL_URL}/api/auth/saml/callback` | Assertion Consumer Service (ACS) URL. |
|
||||
| `SAML_AUTO_CREATE_USERS` | `true` | Automatically create a local user account on first SAML login. |
|
||||
| `SAML_AUTO_LINK_USERS` | `false` | Link a SAML identity to an existing local user if the email address matches. |
|
||||
| `SAML_DEFAULT_ROLE` | `user` | Role assigned to auto-created SAML users. One of `admin`, `editor`, or `user`. |
|
||||
| `SAML_PROVIDER_NAME` | | Display label for the SAML login button on the frontend (e.g. "Okta", "Azure AD"). If empty, the button says "SAML". |
|
||||
| `SAML_USERNAME_ATTRIBUTE` | | SAML assertion attribute used as the username. If empty, falls back to the email local-part, then NameID. |
|
||||
| `SAML_EMAIL_ATTRIBUTE` | `email` | SAML assertion attribute used as the user's email address. |
|
||||
|
||||
The server refuses to start if `SAML_ENABLED=true` and any of the three required variables (`SAML_IDP_SSO_URL`, `SAML_IDP_CERTIFICATE`, `EXTERNAL_URL`) are missing.
|
||||
|
||||
::: details Security notes
|
||||
Both `wantAuthnResponseSigned` and `wantAssertionsSigned` are hardcoded to `true`. SnapOtter rejects unsigned or improperly signed SAML responses. Assertions from a trusted IdP are treated as email-verified.
|
||||
|
||||
Only SP-initiated login is supported. SnapOtter does not support IdP-initiated (unsolicited) login or Single Logout (SLO). Logging out of SnapOtter does not log the user out of the IdP.
|
||||
:::
|
||||
|
||||
## SP metadata and URLs
|
||||
|
||||
Your IdP needs three values from SnapOtter:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **ACS URL** (Assertion Consumer Service) | `${EXTERNAL_URL}/api/auth/saml/callback` |
|
||||
| **Entity ID** / **Audience URI** | `${EXTERNAL_URL}/api/auth/saml/metadata` |
|
||||
| **SP Metadata** (XML) | `GET ${EXTERNAL_URL}/api/auth/saml/metadata` |
|
||||
|
||||
For example, if `EXTERNAL_URL` is `https://photos.example.com`:
|
||||
|
||||
- ACS URL: `https://photos.example.com/api/auth/saml/callback`
|
||||
- Entity ID: `https://photos.example.com/api/auth/saml/metadata`
|
||||
- Metadata endpoint: `https://photos.example.com/api/auth/saml/metadata` (returns XML)
|
||||
|
||||
Some IdPs can import the SP metadata URL directly, which auto-fills the ACS URL and Entity ID.
|
||||
|
||||
## Provider setup
|
||||
|
||||
### Okta
|
||||
|
||||
1. In the Okta admin console, go to **Applications > Create App Integration**.
|
||||
2. Select **SAML 2.0** and click **Next**.
|
||||
3. Set a name (e.g. "SnapOtter") and click **Next**.
|
||||
4. Configure the SAML settings:
|
||||
- **Single sign-on URL**: Your ACS URL (e.g. `https://photos.example.com/api/auth/saml/callback`)
|
||||
- **Audience URI (SP Entity ID)**: Your Entity ID (e.g. `https://photos.example.com/api/auth/saml/metadata`)
|
||||
- **Name ID format**: EmailAddress
|
||||
- **Application username**: Email
|
||||
5. Under **Attribute Statements**, add `email` mapped to `user.email`.
|
||||
6. Click **Next**, then **Finish**.
|
||||
7. Go to the **Sign On** tab, click **View SAML setup instructions**, and copy:
|
||||
- **Identity Provider Single Sign-On URL** into `SAML_IDP_SSO_URL`
|
||||
- **X.509 Certificate** into `SAML_IDP_CERTIFICATE`
|
||||
|
||||
### Azure AD / Entra ID
|
||||
|
||||
1. In the Azure portal, go to **Microsoft Entra ID > Enterprise applications > New application**.
|
||||
2. Click **Create your own application**, name it "SnapOtter", and select **Integrate any other application you don't find in the gallery**.
|
||||
3. Go to **Single sign-on > SAML** and click **Edit** on the **Basic SAML Configuration** section:
|
||||
- **Identifier (Entity ID)**: Your Entity ID (e.g. `https://photos.example.com/api/auth/saml/metadata`)
|
||||
- **Reply URL (ACS URL)**: Your ACS URL (e.g. `https://photos.example.com/api/auth/saml/callback`)
|
||||
4. Under **SAML Certificates**, download the **Certificate (Base64)**.
|
||||
5. Under **Set up SnapOtter**, copy the **Login URL**.
|
||||
6. Set `SAML_IDP_SSO_URL` to the Login URL and `SAML_IDP_CERTIFICATE` to the downloaded certificate contents.
|
||||
7. Assign users or groups to the application under **Users and groups**.
|
||||
|
||||
### Google Workspace
|
||||
|
||||
1. In the Google Admin console, go to **Apps > Web and mobile apps > Add app > Add custom SAML app**.
|
||||
2. Name the app "SnapOtter" and click **Continue**.
|
||||
3. On the **Google Identity Provider details** page, copy the **SSO URL** and download the **Certificate**. Click **Continue**.
|
||||
4. Configure the Service Provider details:
|
||||
- **ACS URL**: Your ACS URL (e.g. `https://photos.example.com/api/auth/saml/callback`)
|
||||
- **Entity ID**: Your Entity ID (e.g. `https://photos.example.com/api/auth/saml/metadata`)
|
||||
- **Name ID format**: EMAIL
|
||||
- **Name ID**: Basic Information > Primary email
|
||||
5. Click **Continue**, then **Finish**.
|
||||
6. Turn the app **ON** for your organizational units.
|
||||
7. Set `SAML_IDP_SSO_URL` to the SSO URL from step 3 and `SAML_IDP_CERTIFICATE` to the downloaded certificate contents.
|
||||
|
||||
### Generic SAML 2.0 IdP
|
||||
|
||||
For any SAML 2.0 compliant identity provider:
|
||||
|
||||
1. Create a new SAML application/service provider in your IdP.
|
||||
2. Set the **ACS URL** to `${EXTERNAL_URL}/api/auth/saml/callback`.
|
||||
3. Set the **Entity ID** / **Audience** to `${EXTERNAL_URL}/api/auth/saml/metadata`.
|
||||
4. Configure the IdP to send the user's email in an attribute named `email` (or set `SAML_EMAIL_ATTRIBUTE` to match your IdP's attribute name).
|
||||
5. Copy the **IdP SSO URL** and **signing certificate** into `SAML_IDP_SSO_URL` and `SAML_IDP_CERTIFICATE`.
|
||||
|
||||
## User provisioning
|
||||
|
||||
### Auto-create
|
||||
|
||||
When `SAML_AUTO_CREATE_USERS` is `true` (the default), a local user account is created the first time someone logs in via SAML. The role is set to `SAML_DEFAULT_ROLE`.
|
||||
|
||||
The username is derived in this order:
|
||||
|
||||
1. The value of the assertion attribute specified by `SAML_USERNAME_ATTRIBUTE` (if set and present)
|
||||
2. The local-part of the email address (everything before `@`)
|
||||
3. The SAML NameID
|
||||
|
||||
If a username collision occurs, a numeric suffix is appended (e.g. `jane` becomes `jane_2`).
|
||||
|
||||
### Auto-link
|
||||
|
||||
When `SAML_AUTO_LINK_USERS` is `true`, SnapOtter links a SAML identity to an existing local account if the email addresses match. This is useful when you have pre-created user accounts and want them to start using SSO without losing their data.
|
||||
|
||||
::: warning
|
||||
Only enable auto-link if you trust your SAML IdP to verify email addresses. An unverified email from a misconfigured IdP could allow someone to take over another user's account.
|
||||
:::
|
||||
|
||||
### Attribute mapping
|
||||
|
||||
| SnapOtter field | Source | Configuration |
|
||||
|---|---|---|
|
||||
| Email | Assertion attribute | `SAML_EMAIL_ATTRIBUTE` (default: `email`) |
|
||||
| Username | Assertion attribute, email, or NameID | `SAML_USERNAME_ATTRIBUTE` (see derivation order above) |
|
||||
| External ID | NameID | Always the SAML NameID, not configurable |
|
||||
|
||||
## SSO enforcement
|
||||
|
||||
If you want to require all users to log in via SAML (or OIDC) and block local password login, enable SSO enforcement:
|
||||
|
||||
1. Ensure the `sso_enforcement` enterprise feature is licensed (available on team and enterprise plans).
|
||||
2. In **Admin Settings > Security**, toggle **SSO Enforcement** on.
|
||||
3. Set a **break-glass username**: this is the one local account that can still log in with a password, for emergency access if the IdP is unreachable.
|
||||
|
||||
When SSO enforcement is active, any local login attempt (except for the break-glass user) returns a 403 error with the message "Local password login is disabled. Please use SSO."
|
||||
|
||||
::: tip
|
||||
Always configure a break-glass username before enabling SSO enforcement. Without it, you could be locked out of SnapOtter if your IdP goes down.
|
||||
:::
|
||||
|
||||
## Using SAML alongside OIDC
|
||||
|
||||
SAML and OIDC can be enabled simultaneously. When both are active, the login page shows separate buttons for each provider (labeled by `SAML_PROVIDER_NAME` and `OIDC_PROVIDER_NAME`). Users can log in with either method.
|
||||
|
||||
Both providers share the same auto-create, auto-link, and SSO enforcement settings independently: each has its own `*_AUTO_CREATE_USERS`, `*_AUTO_LINK_USERS`, and `*_DEFAULT_ROLE` variables.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Assertion validation failed
|
||||
|
||||
The SAML response signature or assertion signature could not be verified. Check:
|
||||
|
||||
- The certificate in `SAML_IDP_CERTIFICATE` matches the current signing certificate in your IdP (certificates rotate, so check for expiry)
|
||||
- The certificate is in PEM format (begins with `-----BEGIN CERTIFICATE-----`)
|
||||
- The certificate is the full text, not a file path
|
||||
- The ACS URL and Entity ID configured in your IdP match SnapOtter's values exactly (scheme, host, port, path)
|
||||
|
||||
### Missing attributes
|
||||
|
||||
If usernames or emails are empty after login, your IdP may not be sending the expected attributes. Check:
|
||||
|
||||
- Your IdP is configured to release an `email` attribute (or whatever `SAML_EMAIL_ATTRIBUTE` is set to)
|
||||
- If using `SAML_USERNAME_ATTRIBUTE`, verify that attribute is included in the assertion
|
||||
- Some IdPs require explicit attribute mapping configuration before they release claims
|
||||
|
||||
### Clock skew
|
||||
|
||||
SAML assertions include timestamp conditions (`NotBefore`, `NotOnOrAfter`). If your server clock and the IdP clock are out of sync, assertion validation fails. Run NTP on both machines to keep clocks aligned.
|
||||
|
||||
### "SAML is enabled via env but saml_sso enterprise feature is not licensed"
|
||||
|
||||
This warning appears in the server logs when `SAML_ENABLED=true` but the license does not include the `saml_sso` feature. Verify your license key and plan. The `saml_sso` feature is available on team and enterprise plans.
|
||||
|
||||
### Login redirects back with error
|
||||
|
||||
If clicking the SAML login button redirects back to the login page with an error, check the server logs for details. Common causes:
|
||||
|
||||
- The IdP SSO URL is unreachable from the server
|
||||
- The IdP rejected the authentication request (check the IdP's audit logs)
|
||||
- The IdP returned an unsigned response (SnapOtter requires both the response and assertion to be signed)
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
description: Set up SCIM 2.0 provisioning to sync users and groups from your identity provider to SnapOtter. Covers Okta, Azure AD / Entra ID, and custom integrations.
|
||||
---
|
||||
|
||||
# SCIM Provisioning
|
||||
|
||||
SnapOtter implements SCIM 2.0 (System for Cross-domain Identity Management) for automated user and group provisioning. Your identity provider can create, update, deactivate, and reactivate user accounts and sync group memberships automatically.
|
||||
|
||||
::: tip Enterprise feature
|
||||
SCIM provisioning requires an **enterprise** license with the `scim` feature. It is not available on the team plan. Without the feature, all SCIM endpoints (except discovery) return 403.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running SnapOtter instance reachable at a public URL
|
||||
- An enterprise license key with the `scim` feature
|
||||
- Admin access to SnapOtter (the `users:manage` permission is required to generate a SCIM token)
|
||||
- Admin access to your identity provider's provisioning settings
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Generate a SCIM bearer token:
|
||||
|
||||
```bash
|
||||
curl -X POST https://photos.example.com/api/v1/enterprise/scim/token \
|
||||
-H "Cookie: snapotter-session=YOUR_SESSION" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
The response contains the token. Save it immediately; it cannot be retrieved again.
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "a1b2c3d4e5f6...",
|
||||
"message": "Save this token -- it cannot be retrieved again"
|
||||
}
|
||||
```
|
||||
|
||||
2. In your identity provider, configure SCIM provisioning with:
|
||||
- **Base URL**: `https://photos.example.com/api/v1/scim/v2`
|
||||
- **Authentication**: Bearer token (paste the token from step 1)
|
||||
|
||||
## Authentication
|
||||
|
||||
SCIM endpoints use a dedicated Bearer token, separate from user sessions and API keys.
|
||||
|
||||
### Generating a token
|
||||
|
||||
`POST /api/v1/enterprise/scim/token` generates a new SCIM token. This endpoint requires a valid session with the `users:manage` permission.
|
||||
|
||||
The token is returned in plaintext exactly once. SnapOtter stores only a scrypt hash. If you lose the token, revoke it and generate a new one.
|
||||
|
||||
Only one SCIM token is active at a time. Generating a new token replaces the previous one.
|
||||
|
||||
### Revoking a token
|
||||
|
||||
`DELETE /api/v1/enterprise/scim/token` revokes the current SCIM token. This endpoint also requires `users:manage`.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
SCIM endpoints are rate-limited to 1000 requests per minute per token. Exceeding this limit returns HTTP 429.
|
||||
|
||||
## Supported resources
|
||||
|
||||
| SCIM resource | SnapOtter concept | Create | Read | Update | Delete |
|
||||
|---|---|---|---|---|---|
|
||||
| User | User account | Yes | Yes | Yes | Soft delete |
|
||||
| Group | Team | Yes | Yes | Yes | Yes |
|
||||
|
||||
::: warning
|
||||
SCIM Groups map to SnapOtter **teams**, not roles. SCIM cannot set a user's role. All users created via SCIM are assigned the `user` role. To change a user's role, use the SnapOtter admin UI.
|
||||
:::
|
||||
|
||||
## User operations
|
||||
|
||||
### Create user
|
||||
|
||||
`POST /api/v1/scim/v2/Users`
|
||||
|
||||
Creates a new user account with `authProvider` set to `scim` and the `user` role. The user is assigned to the Default team. If `active` is `false`, the role is set to `disabled` instead.
|
||||
|
||||
Required attributes: `userName`. Optional: `externalId`, `emails`, `active` (default `true`).
|
||||
|
||||
### List and filter users
|
||||
|
||||
`GET /api/v1/scim/v2/Users`
|
||||
|
||||
Returns a paginated list of users. Supports `startIndex` and `count` query parameters (maximum 200 results per page).
|
||||
|
||||
Filtering supports `eq` (equals) only, on these attributes:
|
||||
|
||||
- `userName eq "jane"`
|
||||
- `externalId eq "ext-12345"`
|
||||
|
||||
Other filter operators and attributes return HTTP 400.
|
||||
|
||||
### Get user
|
||||
|
||||
`GET /api/v1/scim/v2/Users/:id`
|
||||
|
||||
Returns a single user by their SnapOtter user ID.
|
||||
|
||||
### Replace user
|
||||
|
||||
`PUT /api/v1/scim/v2/Users/:id`
|
||||
|
||||
Replaces the user's attributes. Supports `userName`, `externalId`, `emails`, and `active`. Username changes are checked for conflicts (409 if the new username is taken by another user).
|
||||
|
||||
### Patch user
|
||||
|
||||
`PATCH /api/v1/scim/v2/Users/:id`
|
||||
|
||||
Partial update using SCIM PatchOp. Supported operations:
|
||||
|
||||
| Operation | Paths |
|
||||
|---|---|
|
||||
| `replace` | `active`, `userName`, `externalId`, `emails`, `emails[type eq "work"].value`, `name.formatted`, `displayName` |
|
||||
| `add` | Same as `replace` |
|
||||
| `remove` | `externalId`, `emails` |
|
||||
|
||||
The `name.formatted` and `displayName` paths are accepted for compatibility but have no persistent effect (SnapOtter does not store a separate display name).
|
||||
|
||||
Valueless `replace` operations (where the value is an object without a `path`) are also supported, with keys `userName`, `externalId`, `emails`, and `active`.
|
||||
|
||||
### Deactivate user (soft delete)
|
||||
|
||||
`DELETE /api/v1/scim/v2/Users/:id`
|
||||
|
||||
SnapOtter does not hard-delete users via SCIM. Instead, DELETE performs a soft deactivation:
|
||||
|
||||
1. The user's role is changed from its current value (e.g. `editor`) to `disabled:editor`, preserving the original role.
|
||||
2. The user's password is cleared.
|
||||
3. All active sessions are revoked.
|
||||
4. All API keys are revoked.
|
||||
|
||||
The user can no longer log in or use any API keys. Their data (files, history) is retained.
|
||||
|
||||
### Reactivate user
|
||||
|
||||
To reactivate a previously deactivated user, send a `PUT` or `PATCH` request with `active: true`. SnapOtter restores the original role from before deactivation (e.g. `disabled:editor` becomes `editor` again). If the original role cannot be determined, it falls back to `user`.
|
||||
|
||||
::: details Example: deactivate and reactivate via PATCH
|
||||
```json
|
||||
// Deactivate
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{ "op": "replace", "path": "active", "value": false }
|
||||
]
|
||||
}
|
||||
|
||||
// Reactivate
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{ "op": "replace", "path": "active", "value": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
:::
|
||||
|
||||
## Group operations
|
||||
|
||||
SCIM Groups map to SnapOtter teams. Creating a group creates a team. Group membership controls which team a user belongs to.
|
||||
|
||||
### Create group
|
||||
|
||||
`POST /api/v1/scim/v2/Groups`
|
||||
|
||||
Required: `displayName`. Optional: `members` (array of `{ value: userId }`).
|
||||
|
||||
### List and filter groups
|
||||
|
||||
`GET /api/v1/scim/v2/Groups`
|
||||
|
||||
Filtering supports `displayName eq "..."` only. Paginated with `startIndex` and `count` (maximum 200 results per page).
|
||||
|
||||
### Get group
|
||||
|
||||
`GET /api/v1/scim/v2/Groups/:id`
|
||||
|
||||
### Replace group
|
||||
|
||||
`PUT /api/v1/scim/v2/Groups/:id`
|
||||
|
||||
Replaces the group name and full membership list. Existing members not in the new list are moved to the Default team.
|
||||
|
||||
### Patch group
|
||||
|
||||
`PATCH /api/v1/scim/v2/Groups/:id`
|
||||
|
||||
Supports these operations:
|
||||
|
||||
| Operation | Path | Effect |
|
||||
|---|---|---|
|
||||
| `add` | `members` | Adds users to the team |
|
||||
| `remove` | `members[value eq "userId"]` | Moves the user to the Default team |
|
||||
| `replace` | `displayName` | Renames the team |
|
||||
| `replace` | `members` | Replaces all members (removed members move to the Default team) |
|
||||
|
||||
### Delete group
|
||||
|
||||
`DELETE /api/v1/scim/v2/Groups/:id`
|
||||
|
||||
Deletes the team. All members of the deleted team are moved to the Default team. Users are not deactivated or deleted.
|
||||
|
||||
## IdP setup
|
||||
|
||||
### Okta
|
||||
|
||||
1. In the Okta admin console, open your SnapOtter application (or create one).
|
||||
2. Go to the **Provisioning** tab and click **Configure API Integration**.
|
||||
3. Check **Enable API Integration** and enter:
|
||||
- **Base URL**: `https://photos.example.com/api/v1/scim/v2`
|
||||
- **API Token**: The SCIM bearer token generated above
|
||||
4. Click **Test API Credentials**, then **Save**.
|
||||
5. Under **Provisioning > To App**, enable:
|
||||
- **Create Users**
|
||||
- **Update User Attributes**
|
||||
- **Deactivate Users**
|
||||
6. Under **Push Groups**, configure which Okta groups to sync as SnapOtter teams.
|
||||
|
||||
### Azure AD / Entra ID
|
||||
|
||||
1. In the Azure portal, go to your SnapOtter enterprise application.
|
||||
2. Go to **Provisioning** and set **Provisioning Mode** to **Automatic**.
|
||||
3. Under **Admin Credentials**, enter:
|
||||
- **Tenant URL**: `https://photos.example.com/api/v1/scim/v2`
|
||||
- **Secret Token**: The SCIM bearer token generated above
|
||||
4. Click **Test Connection**, then **Save**.
|
||||
5. Under **Mappings**, configure the user and group attribute mappings. The defaults typically work, but verify that `userName` maps to `userPrincipalName` or `mail` as desired.
|
||||
6. Set **Provisioning Status** to **On** and save.
|
||||
|
||||
Azure provisions users and groups on a fixed sync cycle (typically every 40 minutes).
|
||||
|
||||
## Discovery endpoints
|
||||
|
||||
These three endpoints are available without authentication and describe the SCIM server's capabilities:
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /api/v1/scim/v2/ServiceProviderConfig` | Server capabilities and supported features |
|
||||
| `GET /api/v1/scim/v2/Schemas` | User and Group schema definitions |
|
||||
| `GET /api/v1/scim/v2/ResourceTypes` | Available resource types (User, Group) |
|
||||
|
||||
The `ServiceProviderConfig` advertises these capabilities:
|
||||
|
||||
| Feature | Supported |
|
||||
|---|---|
|
||||
| Patch | Yes |
|
||||
| Bulk | No |
|
||||
| Filter | Yes (max 200 results, `eq` operator only) |
|
||||
| Change password | No |
|
||||
| Sort | No |
|
||||
| ETag | No |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Filtering**: Only the `eq` operator is supported. Complex filters, `and`/`or` operators, `co` (contains), and `sw` (starts with) are not implemented.
|
||||
- **Bulk operations**: Not supported.
|
||||
- **Sort and ETag**: Not supported.
|
||||
- **Roles**: SCIM cannot assign SnapOtter roles. All provisioned users get the `user` role.
|
||||
- **MAX_USERS**: The `MAX_USERS` environment variable limit is not enforced on SCIM user creation. If you need to cap user counts, manage assignments in your IdP.
|
||||
- **One token**: Only one SCIM token can be active at a time. If multiple IdPs need SCIM access, they must share the token.
|
||||
- **Groups are teams**: SCIM Groups correspond to teams, not roles or permission groups.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 403 "SCIM provisioning requires an enterprise license with the scim feature"
|
||||
|
||||
Your license does not include the `scim` feature, or no license is configured. SCIM requires an enterprise plan license. Verify `SNAPOTTER_LICENSE_KEY` is set and the license includes the `scim` feature.
|
||||
|
||||
### 401 "Bearer token required"
|
||||
|
||||
The SCIM request did not include an `Authorization: Bearer <token>` header. Check your IdP's provisioning configuration.
|
||||
|
||||
### 401 "Invalid token"
|
||||
|
||||
The token does not match the stored hash. This happens if the token was revoked and regenerated. Update the token in your IdP's provisioning settings.
|
||||
|
||||
### 401 "SCIM not configured"
|
||||
|
||||
No SCIM token has been generated yet. Use the `POST /api/v1/enterprise/scim/token` endpoint to create one.
|
||||
|
||||
### 409 "User already exists" / "userName already taken"
|
||||
|
||||
A user with the same username already exists. This can happen when an IdP retries a failed create. Check for duplicate usernames in the SnapOtter admin panel.
|
||||
|
||||
### 429 "SCIM rate limit exceeded"
|
||||
|
||||
The IdP is sending more than 1000 requests per minute. This typically happens during a large initial sync. Most IdPs automatically retry after the rate limit window resets. If the problem persists, check your IdP's provisioning sync interval.
|
||||
|
||||
### Users deprovisioned but not removed from the UI
|
||||
|
||||
SCIM DELETE is a soft deactivation. Deactivated users still appear in the admin user list with a disabled status. This is by design so their data is preserved. Their role shows as `disabled:<original-role>`.
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
description: Manage users, built-in and custom roles, permissions, API keys, teams, sessions, and the audit log in SnapOtter.
|
||||
---
|
||||
|
||||
# Users, Roles & Permissions
|
||||
|
||||
SnapOtter ships three built-in roles, 17 granular permissions, and support for custom roles with optional per-tool access control. This page covers the full authorization model, API key scoping, team management, and audit logging.
|
||||
|
||||
::: tip Related pages
|
||||
[OIDC / SSO](/guide/oidc) | [SAML SSO](/guide/saml) | [SCIM Provisioning](/guide/scim) | [Security & Hardening](/guide/security)
|
||||
:::
|
||||
|
||||
## Users
|
||||
|
||||
### Creating users
|
||||
|
||||
Admins can create users through the admin panel or the `POST /api/auth/users` endpoint. Each user has a username, role, team assignment, and an optional email address.
|
||||
|
||||
### Default admin
|
||||
|
||||
On first startup SnapOtter creates a default admin account. The credentials come from environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DEFAULT_USERNAME` | `admin` | Username for the initial admin account |
|
||||
| `DEFAULT_PASSWORD` | `admin` | Password for the initial admin account |
|
||||
|
||||
The default admin is required to change their password on first login.
|
||||
|
||||
### Authentication providers
|
||||
|
||||
Users can authenticate through several methods:
|
||||
|
||||
- **Local** -- username and password stored in the SnapOtter database
|
||||
- **OIDC** -- any OpenID Connect provider (see [OIDC / SSO](/guide/oidc))
|
||||
- **SAML** -- SAML 2.0 identity providers (see [SAML SSO](/guide/saml))
|
||||
- **SCIM** -- automated provisioning from an identity provider (see [SCIM Provisioning](/guide/scim))
|
||||
|
||||
### Disabling authentication
|
||||
|
||||
Set `AUTH_ENABLED=false` to disable authentication entirely. In this mode a synthetic anonymous user with the `admin` role is used for all requests. No login is required.
|
||||
|
||||
::: warning
|
||||
Disabling authentication grants full admin access to anyone who can reach the instance. Only use this in trusted environments.
|
||||
:::
|
||||
|
||||
## Built-in roles
|
||||
|
||||
SnapOtter includes three built-in roles. They cannot be modified or deleted.
|
||||
|
||||
### Admin
|
||||
|
||||
All 17 permissions. Full control over the instance.
|
||||
|
||||
`tools:use` `files:own` `files:all` `apikeys:own` `apikeys:all` `pipelines:own` `pipelines:all` `settings:read` `settings:write` `users:manage` `teams:manage` `features:manage` `system:health` `audit:read` `compliance:manage` `webhooks:manage` `security:manage`
|
||||
|
||||
### Editor
|
||||
|
||||
7 permissions. Can use all tools and manage all files and pipelines, but cannot access admin functions.
|
||||
|
||||
`tools:use` `files:own` `files:all` `apikeys:own` `pipelines:own` `pipelines:all` `settings:read`
|
||||
|
||||
### User
|
||||
|
||||
5 permissions. Can use tools and manage their own resources.
|
||||
|
||||
`tools:use` `files:own` `apikeys:own` `pipelines:own` `settings:read`
|
||||
|
||||
## Permissions reference
|
||||
|
||||
| Permission | Description |
|
||||
|---|---|
|
||||
| `tools:use` | Use any processing tool |
|
||||
| `files:own` | View and manage own files |
|
||||
| `files:all` | View and manage all users' files |
|
||||
| `apikeys:own` | Create and manage own API keys |
|
||||
| `apikeys:all` | View all users' API keys |
|
||||
| `pipelines:own` | Create and manage own pipelines |
|
||||
| `pipelines:all` | View and manage all users' pipelines |
|
||||
| `settings:read` | View instance settings |
|
||||
| `settings:write` | Modify instance settings |
|
||||
| `users:manage` | Create, update, and delete user accounts |
|
||||
| `teams:manage` | Create, update, and delete teams |
|
||||
| `features:manage` | Install and manage AI feature bundles |
|
||||
| `system:health` | Access health and readiness endpoints |
|
||||
| `audit:read` | View the audit log and list roles |
|
||||
| `compliance:manage` | Manage GDPR lifecycle and compliance features |
|
||||
| `webhooks:manage` | Configure outbound webhooks |
|
||||
| `security:manage` | Manage security settings (IP allowlist, SSO enforcement) |
|
||||
|
||||
## Custom roles
|
||||
|
||||
Admins with the `users:manage` permission can create custom roles through the admin panel or the roles API.
|
||||
|
||||
### Creating a custom role
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:13490/api/v1/roles \
|
||||
-H "Authorization: Bearer si_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "reviewer",
|
||||
"description": "Can use tools and view all files",
|
||||
"permissions": ["tools:use", "files:own", "files:all", "settings:read"]
|
||||
}'
|
||||
```
|
||||
|
||||
Role names must be 2-30 characters, lowercase alphanumeric with hyphens and underscores.
|
||||
|
||||
### Admin-reserved permissions
|
||||
|
||||
Three permissions are reserved for built-in roles and cannot be assigned to custom roles:
|
||||
|
||||
- `compliance:manage`
|
||||
- `webhooks:manage`
|
||||
- `security:manage`
|
||||
|
||||
The roles API rejects any request that includes these permissions. Only the built-in `admin` role has access to them.
|
||||
|
||||
### Tool-level permissions
|
||||
|
||||
Custom roles can optionally restrict which tools users may access. Two modes are available:
|
||||
|
||||
| Mode | Behavior | License requirement |
|
||||
|---|---|---|
|
||||
| `category` | Restrict by modality (image, video, audio, document, file) | None (free) |
|
||||
| `tool` | Restrict by individual tool ID | Requires the `per_tool_permissions` enterprise feature |
|
||||
|
||||
When `tool` mode is set but the enterprise feature is not available, SnapOtter degrades gracefully and allows access to all tools.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "image-only",
|
||||
"permissions": ["tools:use", "files:own"],
|
||||
"toolPermissions": {
|
||||
"mode": "category",
|
||||
"allowed": ["image"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deleting a custom role
|
||||
|
||||
When a custom role is deleted, all users assigned to it are automatically reassigned to the `user` role.
|
||||
|
||||
## Teams
|
||||
|
||||
Teams group users for storage and retention management. A `Default` team is created on first startup.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | Unique team name (1-50 characters) |
|
||||
| `storageQuota` | number | Per-team storage limit in bytes (works without enterprise) |
|
||||
| `retentionHours` | number | Auto-delete outputs after this many hours (requires `team_retention_overrides`, enterprise) |
|
||||
| `legalHold` | boolean | Prevent automatic deletion of team members' files (requires `legal_hold`, enterprise) |
|
||||
|
||||
::: info
|
||||
The `Default` team cannot be deleted. Teams that still have members cannot be deleted. Reassign members first.
|
||||
:::
|
||||
|
||||
## API keys
|
||||
|
||||
Users can generate API keys for programmatic access. Each key uses the `si_` prefix and is shown only once at creation time.
|
||||
|
||||
### Scoped permissions
|
||||
|
||||
API keys can optionally carry a `permissions` array. When set, the effective permissions for a request are the **intersection** of the user's role permissions and the key's scoped permissions. This means an API key can never escalate beyond the user's own permissions.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:13490/api/v1/api-keys \
|
||||
-H "Authorization: Bearer si_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "CI pipeline key",
|
||||
"permissions": ["tools:use", "files:own"],
|
||||
"expiresAt": "2027-01-01T00:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
### Expiration
|
||||
|
||||
Keys accept an optional `expiresAt` timestamp. Expired keys are rejected at authentication time.
|
||||
|
||||
## Audit log
|
||||
|
||||
SnapOtter records security-relevant events in a structured audit log stored in the `audit_log` database table.
|
||||
|
||||
### Viewing the audit log
|
||||
|
||||
```
|
||||
GET /api/v1/audit-log?page=1&limit=50&action=LOGIN_FAILED&from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z
|
||||
```
|
||||
|
||||
Requires the `audit:read` permission. Supports pagination (`page`, `limit`) and filters (`action`, `ip`, `from`, `to`).
|
||||
|
||||
### Tool operation auditing
|
||||
|
||||
::: warning
|
||||
`TOOL_EXECUTED` events are **not** logged by default. They are opt-in through either of two paths:
|
||||
|
||||
1. Set the `auditToolOperations` admin setting to `true`.
|
||||
2. Hold an active license with the `audit_export` feature (available on both team and enterprise plans).
|
||||
|
||||
Without one of these, individual tool executions are not recorded in the audit log.
|
||||
:::
|
||||
|
||||
### Exporting
|
||||
|
||||
```
|
||||
GET /api/v1/enterprise/audit/export?format=csv&from=2026-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
Requires the `audit:read` permission and the `audit_export` enterprise feature (available on both team and enterprise plans). Supports CSV and JSON formats, filtered by `action`, `actorId`, `targetType`, `targetId`, `from`, and `to`.
|
||||
|
||||
### Tamper-resistant signing
|
||||
|
||||
When enabled, each audit log entry is signed with an HMAC derived from `DATA_ENCRYPTION_KEY`. This requires:
|
||||
|
||||
1. Setting `DATA_ENCRYPTION_KEY` in your environment.
|
||||
2. Enabling the `tamperResistantAudit` admin setting.
|
||||
3. An enterprise license with the `tamper_resistant_audit` feature.
|
||||
|
||||
### Retention
|
||||
|
||||
Set `AUDIT_RETENTION_DAYS` to automatically purge old entries. The default is `0`, which means entries are kept indefinitely.
|
||||
|
||||
### Event reference
|
||||
|
||||
| Event | Category |
|
||||
|---|---|
|
||||
| `LOGIN_SUCCESS`, `LOGIN_FAILED` | Authentication |
|
||||
| `OIDC_LOGIN_SUCCESS`, `OIDC_LOGIN_FAILED` | Authentication |
|
||||
| `SAML_LOGIN_SUCCESS`, `SAML_LOGIN_FAILED` | Authentication |
|
||||
| `LOGOUT` | Authentication |
|
||||
| `USER_CREATED`, `USER_UPDATED`, `USER_DELETED` | User management |
|
||||
| `PASSWORD_CHANGED`, `PASSWORD_RESET` | User management |
|
||||
| `MFA_ENROLLED`, `MFA_DISABLED`, `MFA_VERIFIED`, `MFA_VERIFY_FAILED` | MFA |
|
||||
| `MFA_CHALLENGE_ISSUED`, `MFA_RECOVERY_USED`, `MFA_RESET` | MFA |
|
||||
| `ROLE_CREATED`, `ROLE_UPDATED`, `ROLE_DELETED` | Roles |
|
||||
| `API_KEY_CREATED`, `API_KEY_DELETED` | API keys |
|
||||
| `SETTINGS_UPDATED`, `IP_ALLOWLIST_UPDATED` | Settings |
|
||||
| `FILE_UPLOADED`, `FILE_DELETED` | Files |
|
||||
| `TOOL_EXECUTED` | Tools (opt-in) |
|
||||
| `SCIM_USER_PROVISIONED`, `SCIM_USER_UPDATED`, `SCIM_USER_DEPROVISIONED` | SCIM |
|
||||
| `SCIM_GROUP_SYNCED` | SCIM |
|
||||
| `LEGAL_HOLD_APPLIED`, `LEGAL_HOLD_RELEASED` | Compliance |
|
||||
| `GDPR_EXPORT_INITIATED`, `GDPR_USER_PURGED`, `GDPR_TEAM_PURGED` | Compliance |
|
||||
| `CONFIG_EXPORTED`, `CONFIG_IMPORTED` | Configuration |
|
||||
|
||||
## Session management
|
||||
|
||||
Sessions are cookie-based, controlled by `SESSION_DURATION_HOURS` (default: 168 hours / 7 days).
|
||||
|
||||
### Role changes invalidate sessions
|
||||
|
||||
When an admin changes a user's role, all of that user's active sessions are deleted. The user must log in again to pick up their new permissions.
|
||||
|
||||
### Safety guards
|
||||
|
||||
- **Last-admin protection**: the last remaining admin cannot be demoted to a lower role. The API returns an error if you try.
|
||||
- **Self-delete prevention**: admins cannot delete their own account through the API.
|
||||
+5
-34
@@ -1,37 +1,8 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "SnapOtter"
|
||||
text: "Self-Hosted File Toolkit"
|
||||
tagline: 157 tools for image, video, audio, PDF, and data processing. Resize, compress, convert, remove backgrounds, merge PDFs, trim videos, transcribe audio, and more. Fully offline.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get started
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: API reference
|
||||
link: /api/rest
|
||||
|
||||
features:
|
||||
- title: 157 Tools, 5 Modalities
|
||||
details: "Image: resize, crop, compress, convert, watermark, collage, and more. Video: trim, crop, merge, compress, add subtitles. Audio: trim, normalize, convert, transcribe. PDF: merge, split, compress, watermark, OCR, redact. Data: CSV/JSON/XML conversion, ZIP archives, chart maker."
|
||||
- title: Local AI
|
||||
details: 19 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency, expand canvas with AI fill. All on your hardware, no internet required.
|
||||
- title: Pipelines
|
||||
details: Chain tools into reusable workflows with unlimited steps. Batch process unlimited files at once with a single request.
|
||||
- title: REST API
|
||||
details: Every tool available via API with API key auth. Interactive docs at /api/docs, plus /llms.txt and /llms-full.txt for AI agents.
|
||||
- title: File Library
|
||||
details: Persistent file storage with full version history. Every processing step is tracked so you can trace the full tool chain from original to final output.
|
||||
- title: Teams & Access Control
|
||||
details: Multi-user support with admin/user roles, team grouping, per-resource permissions, and audit logging for all sensitive actions.
|
||||
layout: page
|
||||
title: SnapOtter Docs
|
||||
description: "Documentation for SnapOtter, a self-hosted file manipulation suite. 157 tools for image, video, audio, PDF, and data processing. Local AI, pipelines, REST API."
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
<div class="quick-start-banner">
|
||||
|
||||
```bash
|
||||
docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest
|
||||
```
|
||||
|
||||
</div>
|
||||
<DocsHome />
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"docs:preview": "vitepress preview ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"pagefind": "^1.5.2",
|
||||
"tsx": "^4.22.4",
|
||||
"vitepress": "^1.1.4",
|
||||
"vitepress-plugin-llms": "^1.13.1"
|
||||
"vitepress-plugin-llms": "^1.13.1",
|
||||
"vitepress-plugin-pagefind": "^0.4.20"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user