merge: issue/0024b-security-loader — security/ YAML files + shell/security/ loader
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# 0024 — Sistema centralizado de grupos y permisos
|
||||
|
||||
## Objetivo
|
||||
|
||||
Reemplazar los controles de acceso por agente (`security.roles`, `matrix.filters.allowed_users`) con un sistema centralizado en una carpeta `security/` donde se definen grupos de usuarios, grupos de agentes, y una política de permisos que los vincula. Esto elimina la necesidad de configurar permisos en cada agente individualmente.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Actualmente cada agente tiene su propio bloque `security.roles` en `config.yaml` y `matrix.filters.allowed_users` en `matrix.filters`. Añadir un usuario a varios agentes requiere editar múltiples archivos.
|
||||
- El módulo `pkg/acl/` existe y está completo: resuelve ACLs puras dado un mapa de roles. Lo reutilizamos como motor de evaluación.
|
||||
- La nueva capa `pkg/security/` se apoya en `pkg/acl/` para producir `acl.ACL` por agente a partir de la política centralizada.
|
||||
- La carpeta `security/` en la raíz del proyecto contiene los YAML de grupos y permisos. El launcher los carga una vez y distribuye la ACL resuelta a cada agente.
|
||||
- Se elimina `matrix.filters.allowed_users` y `security.roles` del schema de config de agente una vez que todos los agentes usan la política centralizada.
|
||||
|
||||
**Dependencias:** ninguna (issue autocontenido en 3 fases).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
pkg/security/ NEW — tipos puros + resolución ACL
|
||||
groups.go NEW — UserGroup, AgentGroup
|
||||
policy.go NEW — Permission, AgentPolicy, SecurityPolicy
|
||||
resolver.go NEW — ResolveACL(agentID, policy) → acl.ACL
|
||||
security_test.go NEW — tests de resolución
|
||||
|
||||
security/ NEW — configs centralizados (raíz del proyecto)
|
||||
user-groups.yaml NEW — definición de grupos de usuarios
|
||||
agent-groups.yaml NEW — definición de grupos de agentes
|
||||
permissions.yaml NEW — políticas: qué grupos de usuarios tienen qué permisos en qué grupos de agentes
|
||||
|
||||
shell/security/ NEW — loader impuro
|
||||
loader.go NEW — carga los 3 YAML y construye SecurityPolicy
|
||||
loader_test.go NEW — tests con YAML de ejemplo
|
||||
|
||||
cmd/launcher/main.go MODIFIED — carga security/ al inicio, pasa acl.ACL resuelta a cada Agent
|
||||
agents/runtime.go MODIFIED — acepta acl.ACL pre-resuelta en lugar de RoleCfg
|
||||
internal/config/schema.go MODIFIED — marcar security.roles y matrix.filters.allowed_users como deprecated
|
||||
agents/assistant-bot/config.yaml MODIFIED — eliminar security.roles y allowed_users
|
||||
agents/asistente-2/config.yaml MODIFIED — eliminar security.roles y allowed_users
|
||||
docs/security.md MODIFIED — documentar nuevo sistema
|
||||
CLAUDE.md MODIFIED — mencionar security/ en estructura
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- `pkg/security/` — **puro**: tipos (`UserGroup`, `AgentGroup`, `SecurityPolicy`) y función `ResolveACL()`. Cero I/O.
|
||||
- `shell/security/` — **impuro**: lee YAML del filesystem y construye `SecurityPolicy`.
|
||||
- `cmd/launcher/` — **impuro**: llama al loader, resuelve ACL por agente, inyecta en `Agent{}`.
|
||||
- `agents/runtime.go` — **composición**: recibe `acl.ACL` ya resuelta, la usa en `shouldHandle()` y en la evaluación de permisos.
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Pure core — pkg/security/
|
||||
|
||||
- [ ] **1.1** Crear `pkg/security/groups.go` con tipos `UserGroup{Name, Members []string}` y `AgentGroup{Name, Agents []string}`
|
||||
- [ ] **1.2** Crear `pkg/security/policy.go` con tipos `Permission{UserGroup, Actions []string}`, `AgentPolicy{AgentGroup, Permissions []Permission}`, `SecurityPolicy{UserGroups, AgentGroups, Policies}`
|
||||
- [ ] **1.3** Crear `pkg/security/resolver.go` con `ResolveACL(agentID string, p SecurityPolicy) acl.ACL`: expande grupos de agentes que incluyan `agentID` o `"*"`, expande grupos de usuarios a `acl.Role` list, construye `acl.ACL` vía `acl.FromRoles()`
|
||||
- [ ] **1.4** Soporte de wildcard: `AgentGroup.Agents = ["*"]` aplica a todos los agentes; `UserGroup.Members = ["*"]` aplica a todos los usuarios
|
||||
- [ ] **1.5** Crear `pkg/security/security_test.go` con casos: sin política (ACL vacía), agente en grupo, agente no en grupo, wildcard de agente, wildcard de usuario, múltiples políticas acumulativas
|
||||
|
||||
### Fase 2: Config files + Shell loader
|
||||
|
||||
- [ ] **2.1** Crear `security/user-groups.yaml` con ejemplo: grupos `admins` y `everyone` (members: `["*"]`)
|
||||
- [ ] **2.2** Crear `security/agent-groups.yaml` con ejemplo: grupo `assistants` con los agentes actuales (`assistant-bot`, `asistente-2`), grupo `all` con `agents: ["*"]`
|
||||
- [ ] **2.3** Crear `security/permissions.yaml` con ejemplo: grupo `all` da acción `"ask"` a `everyone`; grupo `all` da `"*"` a `admins`
|
||||
- [ ] **2.4** Crear `shell/security/loader.go` con `Load(dir string) (security.SecurityPolicy, error)` que lee los 3 YAML del directorio y construye el struct. Si el directorio no existe, devuelve `SecurityPolicy{}` vacía (sin error: backward compat).
|
||||
- [ ] **2.5** Crear `shell/security/loader_test.go` con tests: dir vacío → policy vacía, YAMLs válidos → policy correcta, YAML malformado → error claro
|
||||
|
||||
### Fase 3: Integración en launcher y runtime
|
||||
|
||||
- [ ] **3.1** En `cmd/launcher/main.go`: llamar `shell/security.Load("security/")` al inicio; para cada agente llamar `security.ResolveACL(cfg.Agent.ID, policy)` y pasar la `acl.ACL` resultante a `agents.New()`
|
||||
- [ ] **3.2** En `agents/runtime.go`: añadir campo `acl acl.ACL` en `Agent{}`. Extender `agents.New()` para aceptar `acl.ACL` como parámetro adicional (o via `Option`). Usar `a.acl.CanDo()` en `shouldHandle()` y en evaluación de permisos de comandos/tools
|
||||
- [ ] **3.3** En `shell/matrix/listener.go`: eliminar el chequeo de `AllowedUsers` (líneas 285-301 aprox.); el control de acceso ahora está en runtime via `acl.ACL`
|
||||
- [ ] **3.4** En `internal/config/schema.go`: deprecar campos `security.roles` (añadir comentario `// Deprecated: usar security/ centralizado`) y `matrix.filters.allowed_users` (mismo comentario). No eliminar todavía — backward compat.
|
||||
- [ ] **3.5** Actualizar `agents/assistant-bot/config.yaml` y `agents/asistente-2/config.yaml`: eliminar bloques `security.roles` y `matrix.filters.allowed_users` (ahora gestionados centralmente)
|
||||
- [ ] **3.6** Actualizar `security/permissions.yaml` con los permisos reales de los agentes actuales (extraídos de sus configs antes de borrarlos)
|
||||
|
||||
### Fase 4: Tests de integración
|
||||
|
||||
- [ ] **4.1** `go build -tags goolm ./...` compila sin errores
|
||||
- [ ] **4.2** `go test -tags goolm ./pkg/security/...` pasa
|
||||
- [ ] **4.3** `go test -tags goolm ./shell/security/...` pasa
|
||||
- [ ] **4.4** `go test -tags goolm ./...` pasa completo (sin romper tests existentes de pkg/acl)
|
||||
|
||||
### Fase 5: Cleanup y docs
|
||||
|
||||
- [ ] **5.1** Actualizar `docs/security.md` — documentar el sistema de grupos, estructura de los 3 YAML, campos disponibles en cada uno, cómo se resuelven las ACLs
|
||||
- [ ] **5.2** Actualizar `CLAUDE.md` — añadir `security/` en la sección de estructura del proyecto
|
||||
- [ ] **5.3** Añadir `.gitignore` entry si aplica (los YAML de `security/` SÍ se commitean — son config, no secrets)
|
||||
- [ ] **5.4** Evaluar si eliminar definitivamente los campos deprecated del schema en este issue o dejarlo para un issue de limpieza posterior
|
||||
|
||||
---
|
||||
|
||||
## Desglose multi-issue
|
||||
|
||||
Este issue se implementa en 3 sub-issues independientes.
|
||||
|
||||
| Sub-issue | Rama | Alcance | Estado |
|
||||
|-----------|------|---------|--------|
|
||||
| 0024a-security-types | issue/0024a-security-types | pkg/security/ tipos puros + resolver + tests | pendiente |
|
||||
| 0024b-security-loader | issue/0024b-security-loader | security/ YAML files + shell/security/ loader + tests | pendiente |
|
||||
| 0024c-security-integration | issue/0024c-security-integration | Wiring en launcher+runtime, cleanup config schema, update agent configs, docs | pendiente |
|
||||
|
||||
### Feature flag
|
||||
|
||||
Nombre: `centralized-security-groups`
|
||||
Se activa en el último sub-issue (0024c) una vez que todos los agentes usan la política centralizada y se han eliminado los controles per-agente.
|
||||
|
||||
### Progreso por tarea
|
||||
|
||||
- [ ] **1.1** UserGroup, AgentGroup types — 0024a
|
||||
- [ ] **1.2** Permission, AgentPolicy, SecurityPolicy types — 0024a
|
||||
- [ ] **1.3** ResolveACL() function — 0024a
|
||||
- [ ] **1.4** Wildcard support — 0024a
|
||||
- [ ] **1.5** Tests pkg/security/ — 0024a
|
||||
- [ ] **2.1** security/user-groups.yaml — 0024b
|
||||
- [ ] **2.2** security/agent-groups.yaml — 0024b
|
||||
- [ ] **2.3** security/permissions.yaml — 0024b
|
||||
- [ ] **2.4** shell/security/loader.go — 0024b
|
||||
- [ ] **2.5** Tests shell/security/ — 0024b
|
||||
- [ ] **3.1** Launcher wiring — 0024c
|
||||
- [ ] **3.2** Runtime ACL field + New() — 0024c
|
||||
- [ ] **3.3** Remove AllowedUsers from listener — 0024c
|
||||
- [ ] **3.4** Deprecar campos schema — 0024c
|
||||
- [ ] **3.5** Update agent configs — 0024c
|
||||
- [ ] **3.6** Populate permissions.yaml con datos reales — 0024c
|
||||
- [ ] **4.1–4.4** Tests completos — 0024c
|
||||
- [ ] **5.1–5.4** Cleanup y docs — 0024c
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
**Estructura de archivos resultante:**
|
||||
```
|
||||
security/
|
||||
user-groups.yaml
|
||||
agent-groups.yaml
|
||||
permissions.yaml
|
||||
```
|
||||
|
||||
**security/user-groups.yaml:**
|
||||
```yaml
|
||||
groups:
|
||||
admins:
|
||||
members:
|
||||
- "@alice:matrix-af2f3d.organic-machine.com"
|
||||
- "@bob:matrix-af2f3d.organic-machine.com"
|
||||
developers:
|
||||
members:
|
||||
- "@carol:matrix-af2f3d.organic-machine.com"
|
||||
everyone:
|
||||
members: ["*"]
|
||||
```
|
||||
|
||||
**security/agent-groups.yaml:**
|
||||
```yaml
|
||||
groups:
|
||||
assistants:
|
||||
agents:
|
||||
- assistant-bot
|
||||
- asistente-2
|
||||
all:
|
||||
agents: ["*"]
|
||||
```
|
||||
|
||||
**security/permissions.yaml:**
|
||||
```yaml
|
||||
policies:
|
||||
- agent_group: all
|
||||
permissions:
|
||||
- user_group: admins
|
||||
actions: ["*"]
|
||||
- user_group: developers
|
||||
actions: ["ask", "command:help", "command:ping", "tool:*"]
|
||||
- user_group: everyone
|
||||
actions: ["ask"]
|
||||
```
|
||||
|
||||
**Resultado:** Al arrancar, el launcher lee `security/`, resuelve la ACL de cada agente, y se la inyecta. Los agentes ya no tienen `security.roles` ni `allowed_users` en su config individual. Para dar permisos a un nuevo usuario en todos los agentes, basta editar `security/user-groups.yaml`.
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **Reutilizar pkg/acl/ como motor**: `pkg/security/` no reemplaza `pkg/acl/`, lo usa. `ResolveACL()` produce `acl.ACL` que los agentes ya saben consumir. Mínimo cambio en runtime.
|
||||
- **3 YAML separados vs 1 solo archivo**: separar grupos de usuarios, grupos de agentes, y permisos mantiene cada archivo enfocado. Los grupos son estables; los permisos cambian más frecuentemente.
|
||||
- **Backward compat en schema**: deprecar pero no eliminar `security.roles` y `allowed_users` en 0024c. Eliminarlos definitivamente sería un issue de limpieza posterior.
|
||||
- **Loader devuelve policy vacía si no existe security/**: no rompe agentes existentes si el directorio no existe. La ACL vacía equivale a "sin restricciones" (comportamiento actual).
|
||||
- **ACL inyectada via parámetro en agents.New()**: alternativa a `Option{}` para mantener la firma explícita. Más simple y sin abstracción innecesaria.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `pkg/acl/` funcionando (completado en issue 0010)
|
||||
- Agentes compilando con `-tags goolm` (ya funciona)
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Permisos actuales en config.yaml**: antes de eliminar `security.roles` de los configs de agente, leer y migrar todos los roles a `security/permissions.yaml`. Si se olvida alguno, el agente queda sin restricciones o con más acceso del esperado. Mitigación: hacer la migración explícitamente en tarea 3.6 antes de borrar en 3.5.
|
||||
- **Orden de carga en launcher**: si el loader falla, los agentes arrancan sin ACL (acceso abierto). Mitigación: loguear WARNING claro en ese caso; considerar modo estricto (fail-fast) como opción de config futura.
|
||||
- **acl.FromRoles() API**: verificar que `pkg/acl/` expone una función que acepte `[]acl.Role` directamente (no solo `map[string]RoleDef`). Si no existe, añadirla en 0024a.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 0024c — Security integration: wiring, cleanup config, docs
|
||||
|
||||
> Parte c del issue [0024-centralized-security-groups.md](0024-centralized-security-groups.md).
|
||||
> Requiere 0024a y 0024b completados.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Conectar el sistema centralizado de seguridad al launcher y al runtime. Eliminar los controles per-agente (`security.roles`, `matrix.filters.allowed_users`) de los configs de agente. Activar el feature flag. Actualizar docs.
|
||||
|
||||
## Contexto
|
||||
|
||||
- `pkg/security/` y `shell/security/` ya existen (0024a, 0024b).
|
||||
- `agents/runtime.go` ya tiene un campo `acl acl.ACL` (añadido en issue 0010). Verificar si `agents.New()` lo acepta como parámetro o si necesita extenderse.
|
||||
- `shell/matrix/listener.go` tiene checks de `AllowedUsers` que se eliminan (el ACL del runtime los reemplaza).
|
||||
- `internal/config/schema.go` tiene `security.roles` (lines ~290-315) y `matrix.filters.allowed_users` (line ~230) que se deprecan.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
cmd/launcher/main.go MODIFIED
|
||||
agents/runtime.go MODIFIED
|
||||
shell/matrix/listener.go MODIFIED
|
||||
internal/config/schema.go MODIFIED
|
||||
agents/assistant-bot/config.yaml MODIFIED
|
||||
agents/asistente-2/config.yaml MODIFIED
|
||||
dev/feature_flags.json MODIFIED
|
||||
docs/security.md MODIFIED
|
||||
CLAUDE.md MODIFIED
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- `cmd/launcher/` — **impuro**: carga la policy, resuelve ACL, inyecta en `Agent{}`
|
||||
- `agents/runtime.go` — **composición**: recibe `acl.ACL` pre-resuelta
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Migrar permisos existentes
|
||||
|
||||
- [ ] **1.1** Leer los bloques `security.roles` de `agents/assistant-bot/config.yaml` y `agents/asistente-2/config.yaml` y migrarlos a `security/permissions.yaml`
|
||||
- [ ] **1.2** Leer `matrix.filters.allowed_users` de ambos configs y añadir esos usuarios a los grupos correspondientes en `security/user-groups.yaml`
|
||||
- [ ] **1.3** Verificar que `security/permissions.yaml` captura todos los permisos existentes antes de eliminar los bloques per-agente
|
||||
|
||||
### Fase 2: Wiring en launcher y runtime
|
||||
|
||||
- [ ] **2.1** En `cmd/launcher/main.go`: añadir `shellsecurity.Load("security/")` al inicio del proceso de arranque. Si devuelve error, loguear WARN y continuar con policy vacía (no fail-fast — comportamiento conservador)
|
||||
- [ ] **2.2** En `cmd/launcher/main.go`: para cada agente, llamar `security.ResolveACL(cfg.Agent.ID, policy)` y pasar la `acl.ACL` resultante a `agents.New()`. Loguear a nivel DEBUG cuántos roles se resolvieron para el agente.
|
||||
- [ ] **2.3** En `agents/runtime.go`: verificar/añadir que `agents.New()` acepta `acl.ACL` como parámetro. Si ya existe el campo `acl` en `Agent{}`, adaptar la firma de `New()`. Si no existe, añadir campo y lógica de `CanDo()` en `shouldHandle()`.
|
||||
- [ ] **2.4** En `agents/runtime.go`: cuando `a.acl.Empty()` es true (policy vacía), el comportamiento es "sin restricciones" (igual que antes). Cuando no está vacía, `shouldHandle()` verifica `a.acl.CanDo(senderID, "ask")` para mensajes y `a.acl.CanDo(senderID, "command:"+cmd)` para comandos.
|
||||
|
||||
### Fase 3: Limpiar listener y config
|
||||
|
||||
- [ ] **3.1** En `shell/matrix/listener.go`: eliminar el bloque de chequeo de `AllowedUsers` en `shouldHandle()` (líneas ~285-301). El control de acceso ahora lo hace el runtime.
|
||||
- [ ] **3.2** En `shell/matrix/listener.go`: eliminar el invite gating basado en `AllowedUsers` (líneas ~105-119). Las invitaciones se aceptan siempre; el ACL se aplica cuando el usuario habla.
|
||||
- [ ] **3.3** En `internal/config/schema.go`: añadir comentario `// Deprecated: use security/ centralized groups instead` sobre el campo `security.roles` y sobre `matrix.filters.allowed_users`. No eliminar el campo (backward compat temporal).
|
||||
- [ ] **3.4** En `agents/assistant-bot/config.yaml`: eliminar bloque `security.roles` y campo `allowed_users`
|
||||
- [ ] **3.5** En `agents/asistente-2/config.yaml`: eliminar bloque `security.roles` y campo `allowed_users`
|
||||
|
||||
### Fase 4: Activar feature flag
|
||||
|
||||
- [ ] **4.1** En `dev/feature_flags.json`: añadir entrada:
|
||||
```json
|
||||
"centralized-security-groups": {
|
||||
"enabled": true,
|
||||
"issue": "0024",
|
||||
"description": "Sistema centralizado de grupos de usuarios y agentes para control de acceso",
|
||||
"added": "2026-03-08"
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 5: Tests
|
||||
|
||||
- [ ] **5.1** `go build -tags goolm ./...` compila sin errores
|
||||
- [ ] **5.2** `go test -tags goolm ./...` pasa completo
|
||||
- [ ] **5.3** Arrancar el launcher localmente y verificar en logs: `"security policy loaded"`, `"resolved ACL for agent"` a nivel DEBUG/INFO
|
||||
- [ ] **5.4** Verificar que un usuario listado en `admins` puede ejecutar comandos y tools
|
||||
- [ ] **5.5** Verificar que un usuario no listado solo puede hacer `ask` (si la policy lo define así)
|
||||
|
||||
### Fase 6: Docs y cleanup
|
||||
|
||||
- [ ] **6.1** Actualizar `docs/security.md`: añadir sección "Sistema de grupos centralizados" con estructura de los 3 YAML, campos disponibles, ejemplos, y cómo se resuelven las ACLs. Marcar `security.roles` y `allowed_users` como deprecated.
|
||||
- [ ] **6.2** Actualizar `CLAUDE.md`: añadir `security/` en la sección de estructura del proyecto
|
||||
- [ ] **6.3** Cerrar issue 0024: mover `dev/issues/0024-centralized-security-groups.md` y sub-issues a `dev/issues/completed/`
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
Flujo completo en producción:
|
||||
```
|
||||
1. Editar security/user-groups.yaml — añadir @newuser al grupo "developers"
|
||||
2. Reiniciar launcher (o esperar hot-reload si aplica)
|
||||
3. @newuser puede hablar con todos los agentes según los permisos del grupo "developers"
|
||||
Sin tocar ningún config.yaml de agente individual.
|
||||
```
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **No fail-fast en loader**: si `security/` no existe o hay error de parseo, el launcher arranca con ACL vacía (sin restricciones). Preferible a que todos los agentes fallen por un typo en YAML. Se loguea WARN visible.
|
||||
- **Eliminar invite gating**: el listener ya no filtra invites por AllowedUsers. El control ocurre cuando el usuario intenta interactuar. Más simple y consistente.
|
||||
- **Deprecated pero no eliminado del schema**: los campos `security.roles` y `allowed_users` permanecen en el schema para no romper configs externos. Se eliminarán en un issue de limpieza posterior (0025 o similar).
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- 0024a completado
|
||||
- 0024b completado
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Agentes sin permisos si security/permissions.yaml está vacío**: si se eliminan los bloques per-agente antes de migrar a permissions.yaml, los agentes quedan abiertos a todos. Mitigación: hacer la migración (tarea 1.1-1.3) ANTES de eliminar los bloques (tarea 3.4-3.5).
|
||||
- **Firma de agents.New() cambia**: puede requerir actualizar tests existentes del runtime. Verificar antes.
|
||||
@@ -33,6 +33,6 @@ afectados y notas de implementacion.
|
||||
| 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado |
|
||||
| 24 | Grupos y permisos centralizados | [0024-centralized-security-groups.md](0024-centralized-security-groups.md) | pendiente |
|
||||
| 24a | Security types: pkg/security/ | [0024a-security-types.md](completed/0024a-security-types.md) | completado |
|
||||
| 24b | Security loader: shell/security/ | [0024b-security-loader.md](0024b-security-loader.md) | pendiente |
|
||||
| 24b | Security loader: shell/security/ | [0024b-security-loader.md](completed/0024b-security-loader.md) | completado |
|
||||
| 24c | Security integration + cleanup | [0024c-security-integration.md](0024c-security-integration.md) | pendiente |
|
||||
| 25 | Catálogo cron + scaffolder | [0025-cron-scaffolder.md](completed/0025-cron-scaffolder.md) | completado |
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 0024b — Security loader: security/ YAML files + shell/security/ loader
|
||||
|
||||
> Parte b del issue [0024-centralized-security-groups.md](0024-centralized-security-groups.md).
|
||||
> Requiere 0024a (pkg/security/ tipos).
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear la carpeta `security/` en la raíz del proyecto con los YAML de grupos de usuarios, grupos de agentes y permisos. Crear el loader impuro `shell/security/loader.go` que los lee y devuelve un `security.SecurityPolicy`.
|
||||
|
||||
## Contexto
|
||||
|
||||
- `pkg/security/` ya existe (0024a). Este sub-issue añade la capa de persistencia (YAML) y el loader.
|
||||
- Los YAML de `security/` se commitean al repositorio — son configuración de acceso, no secrets.
|
||||
- El código se mergea con feature flag = false (loader creado pero no usado todavía).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
security/ NEW — en raíz del proyecto
|
||||
user-groups.yaml NEW
|
||||
agent-groups.yaml NEW
|
||||
permissions.yaml NEW
|
||||
|
||||
shell/security/ NEW
|
||||
loader.go NEW
|
||||
loader_test.go NEW
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- `security/*.yaml` — datos de configuración (no código)
|
||||
- `shell/security/loader.go` — **impuro**: lee filesystem, parsea YAML, construye `security.SecurityPolicy`
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: YAML files
|
||||
|
||||
- [ ] **1.1** Crear `security/user-groups.yaml`:
|
||||
```yaml
|
||||
# Grupos de usuarios del sistema
|
||||
# Members: lista de Matrix user IDs, o "*" para todos los usuarios
|
||||
groups:
|
||||
admins:
|
||||
members: [] # rellenar con los administradores reales
|
||||
everyone:
|
||||
members: ["*"]
|
||||
```
|
||||
- [ ] **1.2** Crear `security/agent-groups.yaml`:
|
||||
```yaml
|
||||
# Grupos de agentes del sistema
|
||||
# Agents: lista de agent IDs (del campo agent.id en config.yaml), o "*" para todos
|
||||
groups:
|
||||
assistants:
|
||||
agents:
|
||||
- assistant-bot
|
||||
- asistente-2
|
||||
all:
|
||||
agents: ["*"]
|
||||
```
|
||||
- [ ] **1.3** Crear `security/permissions.yaml`:
|
||||
```yaml
|
||||
# Políticas de permisos: para cada grupo de agentes, qué acciones tiene cada grupo de usuarios
|
||||
# Actions: "*" = todo, "ask" = chat libre, "command:<name>" = comandos, "tool:<name>" = tools
|
||||
policies:
|
||||
- agent_group: all
|
||||
permissions:
|
||||
- user_group: admins
|
||||
actions: ["*"]
|
||||
- user_group: everyone
|
||||
actions: ["ask"]
|
||||
```
|
||||
|
||||
### Fase 2: Shell loader
|
||||
|
||||
- [ ] **2.1** Crear `shell/security/loader.go` con función `Load(dir string) (security.SecurityPolicy, error)`:
|
||||
- Lee `<dir>/user-groups.yaml` → `[]security.UserGroup`
|
||||
- Lee `<dir>/agent-groups.yaml` → `[]security.AgentGroup`
|
||||
- Lee `<dir>/permissions.yaml` → `[]security.AgentPolicy`
|
||||
- Si el directorio no existe o está vacío: devuelve `security.SecurityPolicy{}` sin error (backward compat)
|
||||
- Si un archivo no existe individualmente: ese campo queda vacío (no es error)
|
||||
- Si el YAML es inválido: devuelve error con mensaje claro indicando qué archivo falló
|
||||
- [ ] **2.2** Definir structs YAML intermedios (solo para parseo) distintos de los tipos puros de `pkg/security/`. Convertir tras parsear. Esto mantiene `pkg/security/` independiente de `gopkg.in/yaml.v3`.
|
||||
|
||||
### Fase 3: Tests del loader
|
||||
|
||||
- [ ] **3.1** Test: directorio inexistente → policy vacía, sin error
|
||||
- [ ] **3.2** Test: directorio vacío (sin YAML) → policy vacía, sin error
|
||||
- [ ] **3.3** Test: los 3 YAML válidos → policy con todos los campos
|
||||
- [ ] **3.4** Test: solo `user-groups.yaml` presente → user groups poblados, resto vacío
|
||||
- [ ] **3.5** Test: YAML malformado → error con nombre de archivo en el mensaje
|
||||
- [ ] **3.6** Test: `user_group: "*"` y `agent: ["*"]` parseados correctamente como strings literales
|
||||
|
||||
### Fase 4: Cleanup
|
||||
|
||||
- [ ] **4.1** `go build -tags goolm ./...` compila
|
||||
- [ ] **4.2** `go test -tags goolm ./shell/security/...` pasa
|
||||
- [ ] **4.3** `go test -tags goolm ./...` pasa completo
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
// En el launcher (todavía no wired — eso es 0024c)
|
||||
policy, err := shellsecurity.Load("security/")
|
||||
if err != nil {
|
||||
log.Fatal("error loading security policy", err)
|
||||
}
|
||||
// policy.UserGroups, policy.AgentGroups, policy.Policies disponibles
|
||||
acl := security.ResolveACL("assistant-bot", policy)
|
||||
```
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **Structs YAML separados de los tipos puros**: `pkg/security/` no importa `gopkg.in/yaml.v3`. El loader usa tipos intermedios locales y convierte. Mantiene el core verdaderamente puro.
|
||||
- **Directorio no existente = policy vacía**: no fuerza a crear los YAML si no se necesitan (ej: agentes puramente públicos). Backward compat con configuraciones existentes.
|
||||
- **3 archivos separados**: cada uno puede editarse independientemente. Los grupos son más estables que los permisos.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- 0024a completado (`pkg/security/` con tipos y `ResolveACL`)
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Typos en user IDs de YAML**: si un Matrix ID tiene un typo, el usuario no tendrá acceso. No hay validación de formato de ID en este issue — es aceptable para MVP.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Grupos de agentes del sistema
|
||||
# Agents: lista de agent IDs (del campo agent.id en config.yaml), o "*" para todos
|
||||
groups:
|
||||
assistants:
|
||||
agents:
|
||||
- assistant-bot
|
||||
- asistente-2
|
||||
all:
|
||||
agents: ["*"]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Políticas de permisos: para cada grupo de agentes, qué acciones tiene cada grupo de usuarios
|
||||
# Actions: "*" = todo, "ask" = chat libre, "command:<name>" = comandos, "tool:<name>" = tools
|
||||
policies:
|
||||
- agent_group: all
|
||||
permissions:
|
||||
- user_group: admins
|
||||
actions: ["*"]
|
||||
- user_group: everyone
|
||||
actions: ["ask"]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Grupos de usuarios del sistema
|
||||
# Members: lista de Matrix user IDs, o "*" para todos los usuarios
|
||||
groups:
|
||||
admins:
|
||||
members: [] # rellenar con los administradores reales
|
||||
everyone:
|
||||
members: ["*"]
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package security provides the impure loader for security policy YAML files.
|
||||
// It reads security/ directory files and returns a pure security.SecurityPolicy.
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/security"
|
||||
)
|
||||
|
||||
// --- YAML intermediate types (private, only for parsing) ---
|
||||
|
||||
type yamlUserGroups struct {
|
||||
Groups map[string]struct {
|
||||
Members []string `yaml:"members"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
|
||||
type yamlAgentGroups struct {
|
||||
Groups map[string]struct {
|
||||
Agents []string `yaml:"agents"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
|
||||
type yamlPermissions struct {
|
||||
Policies []struct {
|
||||
AgentGroup string `yaml:"agent_group"`
|
||||
Permissions []struct {
|
||||
UserGroup string `yaml:"user_group"`
|
||||
Actions []string `yaml:"actions"`
|
||||
} `yaml:"permissions"`
|
||||
} `yaml:"policies"`
|
||||
}
|
||||
|
||||
// Load reads the security YAML files from dir and returns a SecurityPolicy.
|
||||
// If dir does not exist or is empty, returns an empty policy without error.
|
||||
// If an individual file is missing, that section is left empty.
|
||||
// If a YAML file is malformed, returns an error naming the file.
|
||||
func Load(dir string) (security.SecurityPolicy, error) {
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return security.SecurityPolicy{}, nil
|
||||
}
|
||||
|
||||
userGroups, err := loadUserGroups(filepath.Join(dir, "user-groups.yaml"))
|
||||
if err != nil {
|
||||
return security.SecurityPolicy{}, err
|
||||
}
|
||||
|
||||
agentGroups, err := loadAgentGroups(filepath.Join(dir, "agent-groups.yaml"))
|
||||
if err != nil {
|
||||
return security.SecurityPolicy{}, err
|
||||
}
|
||||
|
||||
policies, err := loadPermissions(filepath.Join(dir, "permissions.yaml"))
|
||||
if err != nil {
|
||||
return security.SecurityPolicy{}, err
|
||||
}
|
||||
|
||||
return security.SecurityPolicy{
|
||||
UserGroups: userGroups,
|
||||
AgentGroups: agentGroups,
|
||||
Policies: policies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadUserGroups(path string) ([]security.UserGroup, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var raw yamlUserGroups
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
groups := make([]security.UserGroup, 0, len(raw.Groups))
|
||||
for name, g := range raw.Groups {
|
||||
groups = append(groups, security.UserGroup{
|
||||
Name: name,
|
||||
Members: g.Members,
|
||||
})
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func loadAgentGroups(path string) ([]security.AgentGroup, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var raw yamlAgentGroups
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
groups := make([]security.AgentGroup, 0, len(raw.Groups))
|
||||
for name, g := range raw.Groups {
|
||||
groups = append(groups, security.AgentGroup{
|
||||
Name: name,
|
||||
Agents: g.Agents,
|
||||
})
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func loadPermissions(path string) ([]security.AgentPolicy, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var raw yamlPermissions
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
policies := make([]security.AgentPolicy, 0, len(raw.Policies))
|
||||
for _, p := range raw.Policies {
|
||||
perms := make([]security.Permission, 0, len(p.Permissions))
|
||||
for _, perm := range p.Permissions {
|
||||
perms = append(perms, security.Permission{
|
||||
UserGroup: perm.UserGroup,
|
||||
Actions: perm.Actions,
|
||||
})
|
||||
}
|
||||
policies = append(policies, security.AgentPolicy{
|
||||
AgentGroup: p.AgentGroup,
|
||||
Permissions: perms,
|
||||
})
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package security_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
shellsecurity "github.com/enmanuel/agents/shell/security"
|
||||
)
|
||||
|
||||
// writeFile is a helper that creates a file in dir with the given content.
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeFile %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.1: directorio inexistente → policy vacía, sin error ---
|
||||
|
||||
func TestLoad_NonExistentDir(t *testing.T) {
|
||||
policy, err := shellsecurity.Load("/tmp/does-not-exist-security-xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 {
|
||||
t.Errorf("expected empty policy, got: %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.2: directorio vacío (sin YAML) → policy vacía, sin error ---
|
||||
|
||||
func TestLoad_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
policy, err := shellsecurity.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 {
|
||||
t.Errorf("expected empty policy, got: %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.3: los 3 YAML válidos → policy con todos los campos ---
|
||||
|
||||
func TestLoad_AllFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, dir, "user-groups.yaml", `
|
||||
groups:
|
||||
admins:
|
||||
members: ["@admin:example.com"]
|
||||
everyone:
|
||||
members: ["*"]
|
||||
`)
|
||||
writeFile(t, dir, "agent-groups.yaml", `
|
||||
groups:
|
||||
assistants:
|
||||
agents:
|
||||
- assistant-bot
|
||||
all:
|
||||
agents: ["*"]
|
||||
`)
|
||||
writeFile(t, dir, "permissions.yaml", `
|
||||
policies:
|
||||
- agent_group: all
|
||||
permissions:
|
||||
- user_group: admins
|
||||
actions: ["*"]
|
||||
- user_group: everyone
|
||||
actions: ["ask"]
|
||||
`)
|
||||
|
||||
policy, err := shellsecurity.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(policy.UserGroups) != 2 {
|
||||
t.Errorf("expected 2 user groups, got %d", len(policy.UserGroups))
|
||||
}
|
||||
if len(policy.AgentGroups) != 2 {
|
||||
t.Errorf("expected 2 agent groups, got %d", len(policy.AgentGroups))
|
||||
}
|
||||
if len(policy.Policies) != 1 {
|
||||
t.Errorf("expected 1 policy, got %d", len(policy.Policies))
|
||||
}
|
||||
if len(policy.Policies[0].Permissions) != 2 {
|
||||
t.Errorf("expected 2 permissions, got %d", len(policy.Policies[0].Permissions))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.4: solo user-groups.yaml → user groups poblados, resto vacío ---
|
||||
|
||||
func TestLoad_OnlyUserGroups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, dir, "user-groups.yaml", `
|
||||
groups:
|
||||
admins:
|
||||
members: ["@admin:example.com"]
|
||||
`)
|
||||
|
||||
policy, err := shellsecurity.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(policy.UserGroups) != 1 {
|
||||
t.Errorf("expected 1 user group, got %d", len(policy.UserGroups))
|
||||
}
|
||||
if policy.UserGroups[0].Name != "admins" {
|
||||
t.Errorf("expected group name 'admins', got %q", policy.UserGroups[0].Name)
|
||||
}
|
||||
if len(policy.AgentGroups) != 0 {
|
||||
t.Errorf("expected no agent groups, got %d", len(policy.AgentGroups))
|
||||
}
|
||||
if len(policy.Policies) != 0 {
|
||||
t.Errorf("expected no policies, got %d", len(policy.Policies))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.5: YAML malformado → error con nombre de archivo en el mensaje ---
|
||||
|
||||
func TestLoad_MalformedYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, dir, "user-groups.yaml", `this: is: not: valid: yaml: [`)
|
||||
|
||||
_, err := shellsecurity.Load(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed YAML, got nil")
|
||||
}
|
||||
if got := err.Error(); len(got) == 0 {
|
||||
t.Fatal("error message is empty")
|
||||
}
|
||||
// Must mention the filename
|
||||
if !containsString(err.Error(), "user-groups.yaml") {
|
||||
t.Errorf("error message should contain filename, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 3.6: "*" como string literal en members y agents ---
|
||||
|
||||
func TestLoad_WildcardStrings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, dir, "user-groups.yaml", `
|
||||
groups:
|
||||
everyone:
|
||||
members: ["*"]
|
||||
`)
|
||||
writeFile(t, dir, "agent-groups.yaml", `
|
||||
groups:
|
||||
all:
|
||||
agents: ["*"]
|
||||
`)
|
||||
|
||||
policy, err := shellsecurity.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(policy.UserGroups) != 1 {
|
||||
t.Fatalf("expected 1 user group, got %d", len(policy.UserGroups))
|
||||
}
|
||||
if len(policy.UserGroups[0].Members) != 1 || policy.UserGroups[0].Members[0] != "*" {
|
||||
t.Errorf("expected members=[\"*\"], got %v", policy.UserGroups[0].Members)
|
||||
}
|
||||
|
||||
if len(policy.AgentGroups) != 1 {
|
||||
t.Fatalf("expected 1 agent group, got %d", len(policy.AgentGroups))
|
||||
}
|
||||
if len(policy.AgentGroups[0].Agents) != 1 || policy.AgentGroups[0].Agents[0] != "*" {
|
||||
t.Errorf("expected agents=[\"*\"], got %v", policy.AgentGroups[0].Agents)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstr(s, sub))
|
||||
}
|
||||
|
||||
func containsSubstr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user