diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcba7a6..431b7535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **API Tokens / Service Accounts (Team Pro):** Generate scoped API tokens for CI/CD pipelines and automation. Three permission levels: read-only, deploy-only, and full-admin. Tokens are hashed at rest (SHA-256) and shown only once at creation. Scope enforcement at middleware level prevents tokens from exceeding their granted permissions. + ### Changed * 14-day trial now defaults to Personal Pro instead of Team Pro — Team Pro features (SSO, audit log, unlimited accounts) require a Team Pro license diff --git a/backend/src/index.ts b/backend/src/index.ts index 45121ad5..a18e0894 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -203,6 +203,7 @@ declare global { interface Request { user?: { username: string; role: 'admin' | 'viewer' }; nodeId: number; + apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin'; } } } @@ -235,6 +236,32 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string }; + + // API token path: scope-based programmatic access + if (decoded.scope === 'api_token') { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); + if (!apiToken || apiToken.revoked_at) { + res.status(401).json({ error: 'API token not found or revoked' }); + return; + } + if (apiToken.expires_at && apiToken.expires_at < Date.now()) { + res.status(401).json({ error: 'API token has expired' }); + return; + } + DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id); + const creator = DatabaseService.getInstance().getUserById(apiToken.user_id); + const roleMap: Record = { + 'read-only': 'viewer', + 'deploy-only': 'viewer', + 'full-admin': 'admin', + }; + req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' }; + req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin'; + next(); + return; + } + // Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens. req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' }; next(); @@ -647,6 +674,8 @@ const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /fleet/snapshot/restore': 'Restored fleet backup', 'PUT /sso/config': 'Updated SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration', + 'POST /api-tokens': 'Created API token', + 'DELETE /api-tokens': 'Revoked API token', }; function getAuditSummary(method: string, apiPath: string): string { @@ -728,6 +757,45 @@ const requireAdmin = (req: Request, res: Response): boolean => { return true; }; +// Scope enforcement for API tokens — restricts which endpoints a token can reach. +const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ + /^\/api\/stacks\/[^/]+\/up$/, + /^\/api\/stacks\/[^/]+\/down$/, + /^\/api\/stacks\/[^/]+\/restart$/, + /^\/api\/stacks\/[^/]+\/pull$/, + /^\/api\/compose\/(up|down|start|stop|restart|pull)$/, +]; + +const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => { + const scope = req.apiTokenScope; + if (!scope) { next(); return; } // Not an API token request + if (scope === 'full-admin') { next(); return; } + + if (scope === 'read-only') { + if (req.method !== 'GET') { + res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' }); + return; + } + next(); + return; + } + + if (scope === 'deploy-only') { + if (req.method === 'GET') { next(); return; } + const fullPath = `/api${req.path}`; + if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) { + next(); + return; + } + res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' }); + return; + } + + res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' }); +}; + +app.use('/api', enforceApiTokenScope); + app.get('/api/license', (_req: Request, res: Response): void => { try { const info = LicenseService.getInstance().getLicenseInfo(); @@ -3096,6 +3164,95 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => } }); +// --- API Token Routes (Team Pro, admin-only, local-only) --- + +app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const { name, scope } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'Token name is required.' }); + return; + } + const validScopes = ['read-only', 'deploy-only', 'full-admin']; + if (!scope || !validScopes.includes(scope)) { + res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` }); + return; + } + + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured.' }); + return; + } + + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user) { + res.status(500).json({ error: 'User not found.' }); + return; + } + + const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + + const id = DatabaseService.getInstance().addApiToken({ + token_hash: tokenHash, + name: name.trim(), + scope: scope as 'read-only' | 'deploy-only' | 'full-admin', + user_id: user.id, + created_at: Date.now(), + expires_at: null, + }); + + res.status(201).json({ id, token: rawToken }); + } catch (error) { + console.error('[ApiTokens] Create error:', error); + res.status(500).json({ error: 'Failed to create API token' }); + } +}); + +app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user) { res.status(500).json({ error: 'User not found.' }); return; } + const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id); + // Never expose token hashes to the client + const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest); + res.json(sanitized); + } catch (error) { + console.error('[ApiTokens] List error:', error); + res.status(500).json({ error: 'Failed to list API tokens' }); + } +}); + +app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; } + + const apiToken = DatabaseService.getInstance().getApiTokenById(id); + if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; } + + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user || apiToken.user_id !== user.id) { + res.status(403).json({ error: 'You can only revoke your own tokens.' }); + return; + } + + DatabaseService.getInstance().revokeApiToken(id); + res.json({ success: true }); + } catch (error) { + console.error('[ApiTokens] Revoke error:', error); + res.status(500).json({ error: 'Failed to revoke API token' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1e0a0b23..a377caf5 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -123,6 +123,20 @@ export interface AuditLogEntry { summary: string; } +export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin'; + +export interface ApiToken { + id: number; + token_hash: string; + name: string; + scope: ApiTokenScope; + user_id: number; + created_at: number; + last_used_at: number | null; + expires_at: number | null; + revoked_at: number | null; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -294,6 +308,22 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username); + + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'read-only', + user_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); `); // Apply migrations safely (ignore if columns already exist) @@ -767,6 +797,10 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined; } + public getUserById(id: number): User | undefined { + return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; + } + public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined { return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined; } @@ -947,4 +981,39 @@ export class DatabaseService { const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff); } + + // --- API Tokens --- + + public addApiToken(token: Omit): number { + const result = this.db.prepare( + 'INSERT INTO api_tokens (token_hash, name, scope, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(token.token_hash, token.name, token.scope, token.user_id, token.created_at, token.expires_at); + return result.lastInsertRowid as number; + } + + public getApiTokensByUser(userId: number): ApiToken[] { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId) as ApiToken[]; + } + + public getApiTokenByHash(tokenHash: string): ApiToken | undefined { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE token_hash = ?' + ).get(tokenHash) as ApiToken | undefined; + } + + public getApiTokenById(id: number): ApiToken | undefined { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE id = ?' + ).get(id) as ApiToken | undefined; + } + + public revokeApiToken(id: number): void { + this.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ?').run(Date.now(), id); + } + + public updateApiTokenLastUsed(id: number): void { + this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id); + } } diff --git a/docs/docs.json b/docs/docs.json index 1d1a0fa0..843bf4e7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,6 +97,7 @@ "features/atomic-deployments", "features/fleet-backups", "features/audit-log", + "features/api-tokens", "features/sso", "features/licensing" ] diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx new file mode 100644 index 00000000..6f54be68 --- /dev/null +++ b/docs/features/api-tokens.mdx @@ -0,0 +1,79 @@ +--- +title: API Tokens +description: Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflows with granular permission control. +--- + + + API Tokens require a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + + +API tokens let you authenticate external tools — CI/CD pipelines, deployment scripts, monitoring integrations — without sharing user credentials. Each token is scoped to a specific permission level so you can follow the principle of least privilege. + +## Permission scopes + +Every token is created with one of three permission levels: + +| Scope | Allowed actions | +|-------|----------------| +| **Read Only** | `GET` requests only — view stacks, containers, metrics, and settings | +| **Deploy Only** | Everything in Read Only, plus deploy-related actions (up, down, restart, pull) | +| **Full Admin** | Unrestricted access — equivalent to an admin user session | + +Choose the narrowest scope that fits your use case. A CI pipeline that only deploys stacks should use **Deploy Only**, not Full Admin. + +## Creating a token + +1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Team Pro admins only). +2. Click **Create Token**. +3. Enter a descriptive name (e.g., "GitHub Actions deploy") and select a permission scope. +4. Click **Create**. The raw token is displayed **once** — copy it immediately. + + + API Tokens management view in Settings Hub + + + + The token value is shown only at creation time. Sencho stores a SHA-256 hash of the token, not the token itself. If you lose it, revoke and create a new one. + + +## Using a token + +Pass the token as a Bearer token in the `Authorization` header: + + +```bash curl +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-sencho-instance/api/stacks +``` + +```yaml GitHub Actions +- name: Deploy stack + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.SENCHO_TOKEN }}" \ + https://your-sencho-instance/api/compose/up \ + -d '{"stack": "my-app"}' +``` + + +### Scope enforcement + +If a token attempts an action outside its scope, Sencho returns a `403` response with a `SCOPE_DENIED` error code: + +```json +{ + "error": "API token scope \"read-only\" only allows GET requests.", + "code": "SCOPE_DENIED" +} +``` + +## Revoking a token + +Click the trash icon next to any token in the API Tokens settings tab. Revocation is immediate — any in-flight or future requests using the revoked token will receive a `401` response. + +## Security model + +- **Hashed storage** — Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted. +- **Audit trail** — All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username. +- **No expiry by default** — Tokens do not expire automatically. Revoke tokens manually when they are no longer needed. +- **Scope enforcement** — Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints. diff --git a/docs/images/api-tokens/api-tokens-overview.png b/docs/images/api-tokens/api-tokens-overview.png new file mode 100644 index 00000000..6bc52fa2 Binary files /dev/null and b/docs/images/api-tokens/api-tokens-overview.png differ diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx new file mode 100644 index 00000000..8badb091 --- /dev/null +++ b/frontend/src/components/ApiTokensSection.tsx @@ -0,0 +1,252 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { apiFetch } from '@/lib/api'; +import { TeamProGate } from './TeamProGate'; +import { TierBadge } from './TierBadge'; +import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; + +interface ApiTokenListItem { + id: number; + name: string; + scope: string; + created_at: number; + last_used_at: number | null; + expires_at: number | null; + revoked_at: number | null; +} + +const SCOPE_LABELS: Record = { + 'read-only': 'Read Only', + 'deploy-only': 'Deploy Only', + 'full-admin': 'Full Admin', +}; + +const SCOPE_BADGE_VARIANT: Record = { + 'read-only': 'default', + 'deploy-only': 'secondary', + 'full-admin': 'destructive', +}; + +function formatDate(ts: number): string { + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatRelative(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return formatDate(ts); +} + +export function ApiTokensSection() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [showForm, setShowForm] = useState(false); + const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null); + + const [formName, setFormName] = useState(''); + const [formScope, setFormScope] = useState('read-only'); + + const fetchTokens = async () => { + try { + const res = await apiFetch('/api-tokens', { localOnly: true }); + if (res.ok) { + const data: ApiTokenListItem[] = await res.json(); + setTokens(data.filter(t => !t.revoked_at)); + } + } catch { /* ignore */ } finally { setLoading(false); } + }; + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { fetchTokens(); }, []); + + const handleCreate = async () => { + if (!formName.trim()) { + toast.error('Token name is required.'); + return; + } + setCreating(true); + try { + const res = await apiFetch('/api-tokens', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ name: formName.trim(), scope: formScope }), + }); + if (res.ok) { + const data = await res.json(); + setNewToken({ id: data.id, token: data.token }); + setShowForm(false); + setFormName(''); + setFormScope('read-only'); + fetchTokens(); + toast.success('API token created.'); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to create token.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error.'); + } finally { setCreating(false); } + }; + + const handleRevoke = async (id: number) => { + try { + const res = await apiFetch(`/api-tokens/${id}`, { method: 'DELETE', localOnly: true }); + if (res.ok) { + toast.success('API token revoked.'); + fetchTokens(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to revoke token.'); + } + } catch { toast.error('Network error.'); } + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard.`); + }; + + return ( + +
+
+
+

+ API Tokens +

+

+ Generate scoped tokens for CI/CD pipelines, scripts, and automation. +

+
+ +
+ + {/* Create form */} + {showForm && ( +
+
+ + setFormName(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ )} + + {/* Token reveal (shown once after creation) */} + {newToken && ( +
+
+ Token created — copy it now +
+

This token will not be shown again. Store it securely.

+
+ {newToken.token} + +
+ +
+ )} + + {/* Loading state */} + {loading && ( +
+ + +
+ )} + + {/* Empty state */} + {!loading && tokens.length === 0 && !showForm && ( +
+ +

No API tokens yet.

+

Create one to authenticate CI/CD pipelines and scripts.

+
+ )} + + {/* Token list */} + {!loading && tokens.map(token => ( +
+
+
+ + {token.name} + + {SCOPE_LABELS[token.scope] || token.scope} + +
+ + + + + + + Revoke API token? + + Revoking {token.name} will immediately invalidate it. Any pipelines or scripts using this token will stop working. + + + + Cancel + handleRevoke(token.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + Revoke + + + + +
+
+ + + Created {formatDate(token.created_at)} + + + Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'} + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5ffac0b3..99bdf1f9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -28,6 +28,7 @@ import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from './TierBadge'; import { ProGate } from './ProGate'; import { SSOSection } from './SSOSection'; +import { ApiTokensSection } from './ApiTokensSection'; interface Agent { type: 'discord' | 'slack' | 'webhook'; @@ -49,7 +50,7 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'users' | 'sso' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; +type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; interface WebhookItem { id: number; @@ -657,7 +658,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -1001,6 +1002,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( } label="SSO" /> )} + {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( + } label="API Tokens" /> + )} } @@ -1460,6 +1464,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )} + {activeSection === 'api-tokens' && ( + + )} + {activeSection === 'developer' && (
diff --git a/frontend/src/components/TeamProGate.tsx b/frontend/src/components/TeamProGate.tsx index 7b4e09f6..85b016fd 100644 --- a/frontend/src/components/TeamProGate.tsx +++ b/frontend/src/components/TeamProGate.tsx @@ -46,7 +46,7 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG

{featureName} requires Sencho Team Pro

- Unlock team features like SSO authentication, audit logging, and unlimited user accounts with a Sencho Team Pro license. + Unlock team features like SSO authentication, audit logging, API tokens, and unlimited user accounts with a Sencho Team Pro license.