En vous promenant sur Beamreactor, nous stockons votre IP 48h pour des raisons de sécurité.

Markdown-Reader

Wissensdatenbank › BEAMREACTOR_ACCESS_RIGHTS

Beamreactor Access Rights

Droits d'accès de BeamReactor #

Version: 1.0 — 2026-09-23

Cette documentation décrit qui a le droit de faire quoi dans le moteur, et surtout où cette décision est prise.

Elle ne concerne pas un plugin en particulier : elle définit le contrat entre les voies d'entrée (web, API, console, cron, agent) et le moteur.

BeamReactor repose sur un principe simple :

Une seule porte de décision — secure() — et autant de voies pour y arriver.

Il n'y a pas de second contrôleur, pas de dossier api/, pas de moteur de droits parallèle. Une clé d'API remplace un cookie de session ; un cron pose un contexte ; un agent forge une session. Dans tous les cas, ce qui décide ensuite est la même fonction.

1. Les deux axes : niveau et groupe

Ils sont orthogonaux, et c'est délibéré.

AxeQuestionOù il vitEffet
NiveauQu'est-ce que je peux faire ?colonne user_level de la table des comptesUn plafond : plus on monte, plus on peut
GroupeQu'est-ce que je peux voir ?colonne JSON user_groups + registre usergroupsUn découpage : une liste blanche de plugins sous le niveau

La table des comptes ne s'écrit jamais en dur. Son nom est $cfg['dbtable'], renommable par site — c'est un durcissement (une instance peut garder une table users en leurre). Toute requête la relit dans $cfg au moment de la requête, jamais figée dans une constante.

Les niveaux #

php
define('BASE_LEVEL_USER',      0);
define('BASE_LEVEL_HIGHUSER',  100);
define('BASE_LEVEL_MODERATOR', 500);
define('BASE_LEVEL_ADMIN',     1000);
define('BASE_LEVEL_OVERMIND',  1500);

Règles fermes :

  • Toujours la constante, jamais le nombre. secure('BASE_LEVEL_ADMIN'), pas secure(1000). Les valeurs sont libres (1500, 30000…) ; seul l'ordre est garanti.
  • secure(0) ne veut pas dire « public » : ça veut dire « il faut être connecté », quel que soit le niveau. Une page vraiment publique n'appelle pas secure().
  • Un plugin déclare ses propres constantes par base_user_levels('monplugin'), qui crée MONPLUGIN_LEVEL_USER… alignées sur les BASE_LEVEL_*. Une surcharge se fait dans la conf du plugin, après $basedisplevel :

```php

defined('MONPLUGIN_LEVEL_ADMIN') or define('MONPLUGIN_LEVEL_ADMIN', BASE_LEVEL_MODERATOR);

```

Les niveaux de fonction #

Un palier (CONTENT_LEVEL_HIGHUSER) dit « quel niveau de quel plugin », jamais « quelle fonction » : la même constante garde des endroits différents. Une fonction est une constante nommée dont la valeur est un palier, déclarée dans $cfg['levels'] de cog.php :

php
$cfg['levels'] = [
	'CONTENT_LEVEL_PUBLISH'      => 'CONTENT_LEVEL_HIGHUSER',
	'MACHIN_LEVEL_NUCLEARBUTTON' => 'MACHIN_LEVEL_OVERMIND',
];
// dans le plugin :
if (!secure('CONTENT_LEVEL_PUBLISH')) …
  • base_user_levels() lit la déclaration elle-même (aucun appelant ne la passe) : paliers d'abord, puis fonctions résolues sur les paliers — une surcharge de palier posée avant est héritée.
  • Valeur = un nom de palier (BASE_LEVEL_* ou <MÊME PLUGIN>_LEVEL_<PALIER>), jamais un nombre. Valeur refusée, ou clé qui surcharge un palier : avertissement, constante laissée indéfinie.
  • Fonction non déclarée = niveau inconnu = session détruite (le message dit où déclarer). cog.php est par site : une fonction appelée par un plugin doit être déclarée sur chaque site.
  • Le nom est l'identité de la fonction : le niveau reste le plafond, le groupe dit si le membre l'a (ci-dessous). Seules les fonctions déclarées sont découpables par groupe ; paliers et entiers ne le sont pas.

Les groupes #

Un groupe est un slug dans la table usergroups, portant une liste de plugins autorisés. L'appartenance vit dans la colonne JSON user_groups de la table des comptes.

  • Un groupe ne donne jamais de droit : il en retire. Un membre du groupe administratif reste borné par son niveau.
  • Aucun groupe, ou aucune liste déclarée = aucun effet. UserGroups::allows() rend true quand il n'y a rien à dire — un site qui n'utilise pas les groupes ne change pas de comportement.
  • La vérification se fait au dispatch (index.php), pas sur le menu : pages, widgets et handlers d'un même plugin passent tous par plugins/… et sont donc tous couverts. Refus = 403, ou l'enveloppe [0, {"reason":"group"}] pour un .mod.
  • Une colonne user_groups malformée ne donne pas « tous les plugins » : elle donne la liste vide (fail-closed).
  • Une liste peut citer des fonctions (CONTENT_LEVEL_PUBLISH) à côté des plugins (content). Par groupe, puis OU entre groupes : le groupe cite des fonctions d'un plugin → le plugin s'ouvre, limité à celles-là ; il cite le plugin seul → toutes ses fonctions ; rien du plugin → refus. secure() interroge UserGroups::allowsFunction() une fois le niveau acquis.

2. La porte : `secure()`

lib/functions.lib.php. Toute décision de droit passe par elle. Elle rend true/false, mais surtout : elle sanctionne.

Ordre de lecture :

  1. Branche agent — si le contexte le demande et qu'un en-tête Authorization: AP2-Signature est présent, la signature décide. Signature invalide = refus immédiat, jamais de repli sur la voie humaine. Un niveau de fonction y est refusé : un agent n'a pas d'identité à rapprocher de groupes.
  2. Traduction du niveau. Constante absente : base_user_levels() du plugin, retrouvé en coupant sur _LEVEL_ (LLM_RAG_LEVEL_ADMIN → llm_rag). Toujours inconnue = anomalie : Sessions::terminate(). Pas de repli sur 0 (ce serait « tout loggé »), pas de refus poli — on ne demande pas un niveau qui n'existe pas.
  3. Le mur. Session vierge de toute clé d'authentification = simple visiteur : refus poli, rien à punir. secure() tourne sur chaque requête ; un terminate ici raserait les états pré-login légitimes (2FA en cours, jeton de dépôt, langue).
  4. Session incohérente. Une session qui prétend être authentifiée mais à qui il manque une clé (user_ip, user_level, websitename, email_address, first_name) est forgée ou corrompue : destruction immédiate. Le refus poli d'avant laissait sonder les entrées sans jamais déclencher de représailles.
  5. Niveau insuffisant = cas normal (un USER devant une page ADMIN) : simple refus.
  6. Vérifications critiques, toutes à destruction : IP client illisible ou différente de celle de la session, site différent, compte introuvable ou dont l'email/prénom ne correspondent plus.
  7. Groupes, pour un niveau de fonction déclaré seulement : la fonction doit figurer dans la liste blanche du membre. Refus poli — le compte est légitime, il n'a pas cette fonction. Pas d'user_id en session = refus.

Le testrunner (suite Secure) joue ces étapes contre un miroir et contre le vrai secure() : toute modification se porte dans plugins/testrunner/bin/secure_stub.inc.php et secure_scenarios.inc.php.

La règle fail-closed #

Un niveau inconnu, une session incohérente : ce n'est jamais « refuser et continuer ». C'est une anomalie, donc une session détruite.

C'est la règle la plus importante du fichier. Ne jamais toucher à la sécurité sans l'annoncer explicitement.

Après le refus #

  • forbids() — redirection HTTP vers le formulaire de connexion ; si les en-têtes sont déjà partis (motif « frameheader avant secure », courant dans les plugins d'admin), bascule en redirection JS + repli 401 affiché dans le cadre.
  • Motif d'ordre : frameheader() avant secure()/forbids(), pour que le refus s'affiche dans le cadre du site et non sur une page nue.

3. Les voies d'entrée

Cinq façons d'arriver devant secure(). La porte ne change pas ; seul le moyen de prouver qui on est change.

VoieQui prouve l'identitéCanal ($REQUEST_CHANNEL)Contexte posé
Webcookie de sessionhttp—
API machineclé Bearer brk_…apiAPI_CONTEXT
Agent (paiements)signature AP2agent—
Cronappel interne du planificateur(inchangé)CRON_CONTEXT
Agent MCPsession forgée depuis un compte dédié(inchangé)AGENT_CONTEXT
Console (CLI)rien — voir §5(aucun)—

3.1 Web #

Le cas ordinaire. Cookie, session PHP, secure().

3.2 API machine (lib/apiauth) #

Une clé présentée dans Authorization: Bearer brk_… (ou X-BeamReactor-Key quand le serveur avale Authorization) remplace le cookie. ApiGate::open() est appelé par le contrôleur frontal à la place de session_start() :

  1. cookies de session désactivés, $_SESSION vidé ;
  2. la clé est authentifiée (registre api_keys, secret haché, jamais stocké en clair) ;
  3. la persona de la clé — un vrai compte, avec son niveau et ses groupes — est installée dans $_SESSION ;
  4. la requête continue comme n'importe quelle autre : gardes d'inc/, secure(), modules.

Conséquence à retenir : une clé d'API ne donne aucun droit propre. Elle emprunte ceux de sa persona. Un ADMIN émet la clé, mais c'est le niveau du porteur qui décide.

Contrat HTTP :

CodeCas
401clé absente, malformée, inconnue, révoquée, expirée
403persona bannie, ou secure() du module refusé
400l'objet demandé n'est pas un module (.mod) — l'API ne sert que des modules
200enveloppe habituelle des modules

Un refus rend la même enveloppe que le reste du moteur : [0, {"reason": "...", "error": "..."}].

Chaque appel laisse une ligne dans api_journal — forme et coût, jamais le contenu : clé, persona, IP, méthode, objet, statut, octets reçus, durée, motif de refus. Écrite à l'arrêt du script, refus compris.

3.3 Cron #

Le planificateur pose CRON_CONTEXT avant d'inclure un handler. Le motif standard d'un handler :

php
if (!defined('CRON_CONTEXT') && !secure('BASE_LEVEL_ADMIN')) die('forbidden');

Lu : « soit c'est le planificateur qui m'appelle, soit c'est un humain, et alors il lui faut le niveau. » Une exécution manuelle depuis l'interface ne pose pas CRON_CONTEXT : le handler voit la vraie session de l'admin.

3.4 Agent MCP #

Agent::run() amorce une session factice peuplée depuis un compte dédié en base, pose AGENT_CONTEXT, et travaille sur une liste blanche d'outils serveur (jamais filesystem, jamais dom_bridge). secure() lit $_SESSION exactement comme pour un humain : les outils voient l'agent comme un compte ordinaire. AGENT_CONTEXT permet à un outil de durcir une action pour un agent — jamais de l'assouplir.

4. Les gardes en amont de `secure()`

secure() décide qui a le droit. D'autres couches décident ce qui est seulement atteignable. Elles ne se remplacent pas.

4.1 Apache #

Apache ne doit jamais être un chemin d'exécution.

  • lib/.htaccess : DENY FROM ALL — rien sous lib/ n'est servi.
  • plugins/.htaccess : tout refusé sauf les médias.
  • user/.htaccess, var/.htaccess : idem.

Un script de console sous lib/apiauth/bin/ ou plugins/pipeline/bin/ répond donc 403 en HTTP. (Détail de cette politique : Politique .htaccess.)

4.2 La sentinelle du fichier #

Tout fichier inclus par le moteur — lib, conf, handler, outil — commence par :

php
if(!function_exists('frameheader')) die('forbidden');

Ce n'est pas un contrôle de droits : c'est la preuve que le fichier a été inclus par le moteur et non appelé directement. Ceinture et bretelles avec Apache.

4.3 Le singleton OVERMIND #

BeamReactor n'a qu'un overmind, par décret : son créateur, ancré par $cfg[10] (email en dur dans cog.php, hors de portée d'écriture web).

Un second compte au niveau effectif ≥ BASE_LEVEL_OVERMIND n'est pas une erreur de configuration : c'est la preuve d'une compromission (écriture directe en base, escalade, trahison d'un admin). Le moteur ne corrige pas en silence — il fige le site, bruyamment, jusqu'à intervention humaine au système de fichiers (var/OVERMIND.lock).

La comparaison porte sur l'entier effectif, jamais sur le nom : un niveau stocké en clair ('BASE_LEVEL_OVERMIND') comme en numérique brut (999999) est attrapé pareillement.

4.4 Les outils MCP #

Chaque outil déclare son niveau par une méthode statique level(), vérifiée au centre dans MCP::execute() — jamais dupliquée dans chaque outil :

php
public static function level(): string { return 'BASE_LEVEL_USER'; }   // write_notepad
public static function level(): int    { return 1500; }                 // filesystem (OVERMIND)

Depuis la 4.5.0, les arguments sont aussi validés contre l'inputSchema déclaré par l'outil, avant run() : un JSON bien formé mais aux arguments faux (champ inconnu, type faux, obligatoire absent) ne parvient plus à l'outil. Le pare-feu (ContextGuardian) filtre en plus l'entrée et la sortie.

5. La console (CLI) : il n'y a pas de droits, et c'est voulu

Côté shell, BeamReactor n'applique aucun contrôle d'accès. Le modèle est :

Être sur la machine = tout pouvoir. La couche de droits, c'est le système de fichiers.

Ce n'est pas un oubli. Qui peut lancer php lib/apiauth/bin/apikey.php peut aussi lire user/conf/cog.php (identifiants de base) et user/conf/secret.key.php, donc faire la même chose directement en SQL. Un verrou en PHP serait un verrou à l'intérieur quand la porte d'entrée est ouverte — du théâtre.

Ce qui existe réellement côté console :

MécanismeCe que c'estCe que ce n'est pas
if (PHP_SAPI !== 'cli') die('forbidden');une garde de direction : le script ne s'exécute pas par le webun contrôle de droits
lib/.htaccess, plugins/.htaccessApache ne sert pas les bin/idem
FairQueue::actAs(), pipeline_jobs.submitted_byune persona d'ordonnancement : quel seau est débitéune identité de droits
api_journal.actorune piste d'audit : qui était au clavierune authentification

Points d'entrée console du moteur :

text
lib/apiauth/bin/apikey.php            émission/révocation/journal des clés d'API
plugins/pipeline/bin/dispatcher.php   ordonnanceur résident de la file
plugins/pipeline/bin/worker.php       une étape d'un job (process jetable)
plugins/notepad/bin/rekey_notepad.php re-chiffrement du notepad
plugins/testrunner/bin/secure_scenario.php  scénarios secure() en process jetable

secure() en CLI #

Sans session, $_SESSION est vide : secure() rend toujours false (« simple visiteur »). Un script de console qui a besoin de droits doit donc, explicitement :

  • poser CRON_CONTEXT (handler de cron), ou
  • forger une session depuis un compte réel (Agent::run), ou
  • ne pas passer par secure() du tout — ce que font le dispatcher et le worker, qui agissent sur la base sans persona.

La persona d'un job #

Le pipeline porte submitted_by : la persona qui a soumis le document (0 en CLI). Le worker prend ses tickets au nom de cette persona (FairQueue::actAs()), c'est son seau qui est débité, et la file est ordonnée par la priorité de ce seau.

C'est une question de partage équitable, pas de droits : le quota est une priorité, jamais un refus. Une persona à sec attend plus longtemps ; elle n'est jamais éconduite.

La trace des actes de console #

Un appel HTTP se désigne tout seul : la clé présentée dit quelle persona agit. Un acte de console n'a ni clé ni session — api_keys.created_by y vaut 0.

Depuis la migration 002_journal_actor, émission et révocation en console laissent une ligne de journal :

text
2026-09-23 01:28:10.457  Genoh@Stormwing   clé 6  user 214  CLI  apikey:issue   200
2026-09-23 01:28:15.148  Genoh@Stormwing   clé 6  user —    CLI  apikey:revoke  200
  • method = CLI, ip vide — il n'y a pas d'appelant réseau, on ne lui en invente pas ;
  • actor = utilisateur système et hôte, via ApiKeys::operator().

Ce n'est pas une identité vérifiée : c'est ce que la machine sait de la personne devant le clavier. Sur une installation client (RGPD art. 9), « qui a émis cette clé, quand » doit avoir une réponse — c'est celle-là.

Émission par l'interface (plugin ulev) : created_by porte l'identifiant de l'admin, la ligne de clé suffit.

6. Aide-mémoire pour un développeur de plugin

php
// 1. Ouvrir le cadre AVANT de contrôler : le refus s'affiche dedans.
if($standalone) frameheader($dialmonplugin['title']);

// 2. La constante, jamais le nombre. secure(0) = « connecté », pas « public ».
if(!secure('MONPLUGIN_LEVEL_MODERATOR'))
{
    forbids();
    return;
}

// 3. Un second contrôle pour les actions, jamais un seul pour tout.
$isAdmin = secure('MONPLUGIN_LEVEL_ADMIN');

Et dans un handler (.mod), même discipline, mais le refus est une enveloppe JSON :

php
if(!secure(0)) jreturn($dialmonplugin[70], !!0, 403);

Cinq erreurs classiques :

  1. secure(1000) au lieu de secure('BASE_LEVEL_ADMIN') — le jour où les valeurs changent, le contrôle ment.
  2. secure() avant frameheader() — le refus s'affiche sur une page nue.
  3. Contrôler le menu et pas le handler — le groupe et le niveau se vérifient au dispatch, mais une action sensible mérite son propre secure().
  4. Croire qu'une clé d'API confère des droits — elle emprunte ceux de sa persona.
  5. Ajouter un contrôle en CLI en croyant sécuriser — sur le shell, la sécurité est celle du système de fichiers ; ce qu'il faut ajouter, c'est une trace.

Voir aussi

  • Sanitizer — toute entrée passe par Parser::sanitize/check, quelle que soit la voie.
  • ContextGuardian — pare-feu LLM bidirectionnel, en amont et en aval des outils.
  • Politique .htaccess — ce qu'Apache sert et ne sert pas.
  • Includes — ordre d'exécution et discipline d'autorisation des inclusions globales.
  • lib/apiauth/docs/APIAUTH_FR.md — le détail des clés d'API et du journal.
  • lib/usergroups/docs/USERGROUPS_FR.md — le registre des groupes et leur administration.
  • lib/fairqueue/docs/FAIRQUEUE_FR.md — le partage équitable, qui n'est pas un droit.
de en es fr pt