Let only admins change the global settings
BINDERY_ADMIN_USERS lists the admins of the cwa and proxy modes. Only an admin can change the global settings and the global rules, and read or change personal.ini. Without a list, each user is an admin.
This commit is contained in:
@@ -63,6 +63,7 @@ Environment variables set the configuration. **Restart the container after a cha
|
||||
| `BINDERY_USERNAME` | The user name for `AUTH_METHOD=local`. Case-sensitive. | None |
|
||||
| `BINDERY_PASSWORD_HASH` | The password hash for `AUTH_METHOD=local`. See "Local login". | None |
|
||||
| `CWA_ALLOWED_USERS` | The CWA users that can log in. Empty allows every CWA user. | Empty |
|
||||
| `BINDERY_ADMIN_USERS` | The users that can change the global settings. See "Admins". | Empty |
|
||||
| `PROXY_AUTH_USER_HEADER` | The header in which a reverse proxy sends the user name | `X-Remote-User` |
|
||||
| `PROXY_AUTH_TRUSTED_IPS` | The addresses and networks that can send the user header. See "Logins". | Loopback and private addresses |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None |
|
||||
@@ -210,6 +211,21 @@ open session until that session ends. To stop a CWA user at once, remove the use
|
||||
`CWA_ALLOWED_USERS` and restart Bindery. To end all sessions, delete `/config/.flask_secret` and
|
||||
restart Bindery.
|
||||
|
||||
#### Admins
|
||||
|
||||
In the `cwa` and `proxy` modes, `BINDERY_ADMIN_USERS` lists the users that are admins, for example
|
||||
`BINDERY_ADMIN_USERS=alice, bob`. Only an admin can do these things:
|
||||
|
||||
- Open the Settings page, and change the settings on it.
|
||||
- Change the global replacement rules.
|
||||
- Read and change `personal.ini`, which can hold passwords for story sites.
|
||||
|
||||
Each other user can still add, update and delete stories, and change the settings and the rules of a
|
||||
story. Bindery ignores the case of the names.
|
||||
|
||||
If the list is empty, each user is an admin. The `none` and `local` modes have only one user, who is
|
||||
always an admin. The API key is always an admin too.
|
||||
|
||||
### Host names
|
||||
|
||||
Without a login, a web page that you visit can try to reach Bindery from your browser, under a host
|
||||
@@ -277,7 +293,7 @@ setting. Bindery reads the file for each job, so a change applies to the next jo
|
||||
|
||||
You can edit the file on the Settings page. Bindery refuses a file that FanFicFare cannot read, and
|
||||
shows FanFicFare's warnings for other problems. **The file can hold passwords for story sites, and
|
||||
every user who can log in to Bindery can read them.**
|
||||
each admin can read them.** If `BINDERY_ADMIN_USERS` is not set, each user is an admin. See "Admins".
|
||||
|
||||
Bindery always sets `always_reload_first_chapter`, `mark_new_chapters`, `continue_on_chapter_error`,
|
||||
`make_directories`, `zip_output` and `calibre_series_meta`, because it needs fixed values for them.
|
||||
|
||||
@@ -141,6 +141,17 @@ def _allowed(name: str) -> bool:
|
||||
return not names or name.casefold() in names
|
||||
|
||||
|
||||
def is_admin(identity: Identity) -> bool:
|
||||
"""Whether this user can change the global settings, the global rules and personal.ini.
|
||||
|
||||
Only the cwa and proxy modes have more than one user. The API key is always an admin.
|
||||
"""
|
||||
names = {n.casefold() for n in config.ADMIN_USERS}
|
||||
if not names or identity.via in ("api_key", "none") or active_mode() == AUTH_LOCAL:
|
||||
return True
|
||||
return identity.name.casefold() in names
|
||||
|
||||
|
||||
def _api_key_identity(headers: Mapping[str, str]) -> Identity | None:
|
||||
if not config.API_KEY:
|
||||
return None
|
||||
|
||||
@@ -73,6 +73,8 @@ AUTH_METHOD = os.getenv("AUTH_METHOD", "none").strip().lower()
|
||||
LOCAL_USERNAME = os.getenv("BINDERY_USERNAME", "").strip()
|
||||
LOCAL_PASSWORD_HASH = os.getenv("BINDERY_PASSWORD_HASH", "").strip()
|
||||
CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS")
|
||||
# The users of the cwa and proxy modes that can change the global settings. Without a list, each user can.
|
||||
ADMIN_USERS = _list("BINDERY_ADMIN_USERS")
|
||||
PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip()
|
||||
PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip()
|
||||
# The addresses and networks that can send the user header. The default is each loopback and private address, so
|
||||
|
||||
@@ -11,7 +11,7 @@ const ROUTES = [
|
||||
[/^\/$/, storiesPage, []],
|
||||
[/^\/story\/(\d+)$/, storyPage, ['storyId']],
|
||||
[/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']],
|
||||
[/^\/settings$/, settingsPage, []],
|
||||
[/^\/settings$/, settingsPage, [], true],
|
||||
];
|
||||
|
||||
const app = document.getElementById('bindery-app');
|
||||
@@ -23,6 +23,9 @@ let nav = null;
|
||||
let storyTab = null;
|
||||
let storyTabShown = false;
|
||||
|
||||
// The server refuses the changes of a user who is not an admin. The interface only hides what that user cannot use.
|
||||
const isAdmin = () => auth.admin !== false;
|
||||
|
||||
const currentPath = () => window.location.hash.replace(/^#/, '') || '/';
|
||||
// The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5.
|
||||
const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1];
|
||||
@@ -44,6 +47,14 @@ function notFound(main) {
|
||||
fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list'))));
|
||||
}
|
||||
|
||||
function adminOnly(main) {
|
||||
document.title = 'Settings · Bindery';
|
||||
fill(
|
||||
main,
|
||||
h('section', { class: 'card' }, h('h1', {}, 'Settings'), h('p', {}, 'Only an admin can open the settings. BINDERY_ADMIN_USERS lists the admins.')),
|
||||
);
|
||||
}
|
||||
|
||||
function route() {
|
||||
const main = document.getElementById('main');
|
||||
if (!main) return;
|
||||
@@ -54,9 +65,10 @@ function route() {
|
||||
if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab();
|
||||
const found = ROUTES.find(([pattern]) => pattern.test(path));
|
||||
if (found) {
|
||||
const [pattern, page, names] = found;
|
||||
const [pattern, page, names, admin] = found;
|
||||
const match = path.match(pattern);
|
||||
cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
|
||||
if (admin && !isAdmin()) adminOnly(main);
|
||||
else cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
|
||||
} else {
|
||||
notFound(main);
|
||||
}
|
||||
@@ -81,7 +93,7 @@ function shell() {
|
||||
(auth.mode === 'cwa' || auth.mode === 'local' || auth.logout_url) &&
|
||||
h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'),
|
||||
];
|
||||
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), h('a', { href: '#/settings' }, 'Settings'));
|
||||
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), isAdmin() && h('a', { href: '#/settings' }, 'Settings'));
|
||||
storyTab = h('a', { class: 'story-tab' });
|
||||
storyTabShown = false;
|
||||
fill(
|
||||
|
||||
@@ -197,7 +197,8 @@ export function settingsPage(main, _params, config) {
|
||||
externalLink(INI_DOCS, 'personal.ini documentation'),
|
||||
' and the ',
|
||||
externalLink(DEFAULTS_INI, 'list of all settings with their defaults'),
|
||||
'. The file can hold passwords for story sites. Each user who can log in to Bindery can read them here.',
|
||||
'. The file can hold passwords for story sites. Each admin can read them here. If BINDERY_ADMIN_USERS is not ' +
|
||||
'set, each user is an admin.',
|
||||
),
|
||||
ini,
|
||||
warnings,
|
||||
|
||||
+14
-2
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Response, g, jsonify, request, send_file, session
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from werkzeug.exceptions import Forbidden, HTTPException
|
||||
|
||||
from bindery import auth, config, orchestrator
|
||||
from bindery.cwa import CwaError
|
||||
@@ -139,6 +139,11 @@ def _identity() -> auth.Identity | None:
|
||||
return auth.current_identity(request.headers, session, request.remote_addr)
|
||||
|
||||
|
||||
def _admin_only() -> None:
|
||||
if not auth.is_admin(g.identity):
|
||||
raise Forbidden("Only an admin can do this. BINDERY_ADMIN_USERS lists the admins.")
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_host() -> Any:
|
||||
if request.path == "/api/health" or auth.host_allowed(request.host):
|
||||
@@ -222,6 +227,7 @@ def auth_check() -> Any:
|
||||
"authenticated": identity is not None,
|
||||
"mode": mode,
|
||||
"user": identity.name if identity else None,
|
||||
"admin": identity is not None and auth.is_admin(identity),
|
||||
"logout_url": config.PROXY_AUTH_LOGOUT_URL or None if mode == auth.AUTH_PROXY else None,
|
||||
}
|
||||
|
||||
@@ -271,6 +277,7 @@ def app_config() -> Any:
|
||||
@app.route("/api/settings", methods=["GET", "PUT"])
|
||||
def settings() -> Any:
|
||||
if request.method == "PUT":
|
||||
_admin_only()
|
||||
data = _json()
|
||||
return orchestrator.save_settings(
|
||||
data.get("recent_chapters"),
|
||||
@@ -283,7 +290,10 @@ def settings() -> Any:
|
||||
data.get("main_jobs_count"),
|
||||
link_books=data.get("link_books"),
|
||||
)
|
||||
return orchestrator.get_settings()
|
||||
settings = orchestrator.get_settings()
|
||||
if not auth.is_admin(g.identity):
|
||||
del settings["personal_ini"] # it can hold passwords for story sites
|
||||
return settings
|
||||
|
||||
|
||||
# Jobs
|
||||
@@ -427,6 +437,8 @@ def story_cover(story_id: int) -> Any:
|
||||
@app.route("/api/rules", methods=["GET", "PUT"])
|
||||
def rules(story_id: int | None = None) -> Any:
|
||||
if request.method == "PUT":
|
||||
if story_id is None:
|
||||
_admin_only()
|
||||
data = _json()
|
||||
return orchestrator.save_rules(story_id, _rules(data), data.get("paragraphs", ...))
|
||||
return orchestrator.get_rules(story_id)
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
# TRUST_PROXY: "true"
|
||||
# For AUTH_METHOD=proxy: the address of the proxy. The default trusts each private address.
|
||||
# PROXY_AUTH_TRUSTED_IPS: 172.18.0.3
|
||||
# For the cwa and proxy modes: the users that can change the global settings. Without a list, each user can.
|
||||
# BINDERY_ADMIN_USERS: alice, bob
|
||||
# BINDERY_API_KEY: a-long-random-string
|
||||
ports:
|
||||
# If a reverse proxy on this host is the only way in, publish the port only on the host: 127.0.0.1:8085:8085
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { find, install, server, settle } from './flow-helpers.mjs';
|
||||
|
||||
const ui = install();
|
||||
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
|
||||
|
||||
test('a user who is not an admin gets no settings link and no settings page', async (t) => {
|
||||
t.after(stopPolling);
|
||||
const backend = server(t);
|
||||
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'bob', admin: false });
|
||||
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
|
||||
backend.on('GET', 'jobs', []);
|
||||
backend.on('GET', 'stories', []);
|
||||
await import('../../bindery/static/js/main.js');
|
||||
await settle();
|
||||
assert.ok(find(ui.app, 'a', 'Stories'));
|
||||
assert.equal(find(ui.app, 'a', 'Settings'), undefined);
|
||||
window.location.hash = '#/settings';
|
||||
ui.windowEvents.hashchange();
|
||||
await settle();
|
||||
assert.match(ui.app.textContent, /Only an admin can open the settings/);
|
||||
// The page asks the server for no settings and no rules.
|
||||
assert.deepEqual(backend.sent('GET', 'settings'), []);
|
||||
assert.deepEqual(backend.sent('GET', 'rules'), []);
|
||||
});
|
||||
@@ -71,6 +71,7 @@ def test_cwa_login(client, cwa):
|
||||
"authenticated": False,
|
||||
"mode": "cwa",
|
||||
"user": None,
|
||||
"admin": False,
|
||||
"logout_url": None,
|
||||
}
|
||||
assert login(client, password="wrong").status_code == 401
|
||||
@@ -170,10 +171,54 @@ def test_proxy_header(client, monkeypatch):
|
||||
"authenticated": True,
|
||||
"mode": "proxy",
|
||||
"user": "alice",
|
||||
"admin": True,
|
||||
"logout_url": "https://auth.example.com/logout",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("user", "admin"), [("alice", True), ("Alice", True), ("bob", False)])
|
||||
def test_only_an_admin_changes_the_global_settings(client, monkeypatch, tmp_path, user, admin):
|
||||
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "personal.ini")
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
|
||||
monkeypatch.setattr(config, "ADMIN_USERS", ["alice"])
|
||||
sid = orchestrator.store.create_story("https://www.royalroad.com/fiction/1", "www.royalroad.com", "S", "W", [])
|
||||
headers = {**H, "X-Remote-User": user}
|
||||
status = 200 if admin else 403
|
||||
assert client.get("/api/auth/check", headers=headers).json["admin"] is admin
|
||||
assert ("personal_ini" in client.get("/api/settings", headers=headers).json) is admin
|
||||
for data in ({"recent_chapters": 5}, {"personal_ini": "[defaults]\n"}):
|
||||
assert client.put("/api/settings", json=data, headers=headers).status_code == status
|
||||
assert (orchestrator.get_settings()["recent_chapters"] == 5) is admin
|
||||
assert config.PERSONAL_INI.exists() is admin
|
||||
rules = {"rules": [{"pattern": "a", "replacement": "b"}]}
|
||||
response = client.put("/api/rules", json=rules, headers=headers)
|
||||
assert response.status_code == status
|
||||
assert bool(orchestrator.store.rules(None)) is admin
|
||||
if not admin:
|
||||
assert response.json == {"error": "Only an admin can do this. BINDERY_ADMIN_USERS lists the admins."}
|
||||
# Each user can read the global rules, and change the rules and the settings of a story.
|
||||
assert client.get("/api/rules", headers=headers).status_code == 200
|
||||
assert client.put(f"/api/stories/{sid}/rules", json=rules, headers=headers).status_code == 200
|
||||
assert client.put(f"/api/stories/{sid}/settings", json={"recheck_days": 3}, headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_the_api_key_and_single_user_modes_are_admins(client, monkeypatch):
|
||||
monkeypatch.setattr(config, "ADMIN_USERS", ["alice"])
|
||||
assert client.get("/api/auth/check").json["admin"] # the none mode has no users
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
|
||||
monkeypatch.setattr(config, "API_KEY", "secret")
|
||||
key = {"X-Api-Key": "secret"}
|
||||
assert client.get("/api/auth/check", headers=key).json["admin"]
|
||||
assert client.put("/api/settings", json={"recent_chapters": 2}, headers=key).status_code == 200
|
||||
assert auth.is_admin(auth.Identity("bob", "proxy")) is False
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "local")
|
||||
assert auth.is_admin(auth.Identity("bob", "session"))
|
||||
# Without a list, each user is an admin.
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
|
||||
monkeypatch.setattr(config, "ADMIN_USERS", [])
|
||||
assert auth.is_admin(auth.Identity("bob", "proxy"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trusted", "address", "accepted"),
|
||||
[
|
||||
|
||||
@@ -69,6 +69,7 @@ def test_values_from_the_environment(monkeypatch, reload_config):
|
||||
monkeypatch.setenv("UPDATE_INTERVAL_HOURS", "1.5")
|
||||
monkeypatch.setenv("DEFAULT_LOCKED_FIELDS", "title, authors")
|
||||
monkeypatch.setenv("CWA_ALLOWED_USERS", "alice,bob")
|
||||
monkeypatch.setenv("BINDERY_ADMIN_USERS", "alice")
|
||||
monkeypatch.setenv("CONFIG_DIR", "/data/bindery")
|
||||
monkeypatch.setenv("TRUST_PROXY", "yes")
|
||||
monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ")
|
||||
@@ -83,6 +84,7 @@ def test_values_from_the_environment(monkeypatch, reload_config):
|
||||
assert config.UPDATE_INTERVAL_HOURS == 1.5
|
||||
assert config.DEFAULT_LOCKED_FIELDS == ["title", "authors"]
|
||||
assert config.CWA_ALLOWED_USERS == ["alice", "bob"]
|
||||
assert config.ADMIN_USERS == ["alice"]
|
||||
assert str(config.DB_PATH) == "/data/bindery/bindery.db"
|
||||
assert config.TRUST_PROXY is True
|
||||
assert str(config.CWA_DB_PATH) == "/cwa/app.db"
|
||||
@@ -101,6 +103,7 @@ def test_defaults(monkeypatch, reload_config):
|
||||
"PROXY_AUTH_USER_HEADER",
|
||||
"PROXY_AUTH_TRUSTED_IPS",
|
||||
"ALLOWED_HOSTS",
|
||||
"BINDERY_ADMIN_USERS",
|
||||
"SESSION_COOKIE_SECURE",
|
||||
"TRUST_PROXY",
|
||||
"CWA_DB_PATH",
|
||||
@@ -127,6 +130,7 @@ def test_defaults(monkeypatch, reload_config):
|
||||
"fe80::/10",
|
||||
]
|
||||
assert config.ALLOWED_HOSTS == []
|
||||
assert config.ADMIN_USERS == []
|
||||
assert config.SESSION_COOKIE_SECURE is False
|
||||
assert config.TRUST_PROXY is False
|
||||
assert config.CWA_DB_PATH is None
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_local_login_logout_and_password_hashes(client, local, monkeypatch, meth
|
||||
"authenticated": False,
|
||||
"mode": "local",
|
||||
"user": None,
|
||||
"admin": False,
|
||||
"logout_url": None,
|
||||
}
|
||||
assert client.get("/api/stories").status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user