merge: issue/0024a-security-types — pkg/security/ tipos puros y resolución ACL
This commit is contained in:
@@ -32,7 +32,7 @@ afectados y notas de implementacion.
|
|||||||
| 22c | E2E: Tests de agentes + docs | [0022c-e2e-agent-tests.md](completed/0022c-e2e-agent-tests.md) | completado |
|
| 22c | E2E: Tests de agentes + docs | [0022c-e2e-agent-tests.md](completed/0022c-e2e-agent-tests.md) | completado |
|
||||||
| 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado |
|
| 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 |
|
| 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](0024a-security-types.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](0024b-security-loader.md) | pendiente |
|
||||||
| 24c | Security integration + cleanup | [0024c-security-integration.md](0024c-security-integration.md) | pendiente |
|
| 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 |
|
| 25 | Catálogo cron + scaffolder | [0025-cron-scaffolder.md](completed/0025-cron-scaffolder.md) | completado |
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 0024a — Security types: pkg/security/ — tipos puros y resolución ACL
|
||||||
|
|
||||||
|
> Parte a del issue [0024-centralized-security-groups.md](0024-centralized-security-groups.md).
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Crear el paquete puro `pkg/security/` con los tipos `UserGroup`, `AgentGroup`, `SecurityPolicy` y la función `ResolveACL(agentID, policy) → acl.ACL`. Este paquete es el núcleo de resolución del sistema centralizado de permisos.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
- `pkg/acl/` ya existe con `ACL`, `Role`, `CanDo()`, `RoleFor()`. Lo reutilizamos como motor de evaluación.
|
||||||
|
- Este sub-issue no toca ningún otro archivo. Es pure core sin dependencias nuevas.
|
||||||
|
- El código se mergea con `centralized-security-groups` feature flag = false (no wired todavía).
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/security/ NEW
|
||||||
|
groups.go NEW — UserGroup, AgentGroup
|
||||||
|
policy.go NEW — Permission, AgentPolicy, SecurityPolicy
|
||||||
|
resolver.go NEW — ResolveACL()
|
||||||
|
security_test.go NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron pure core / impure shell
|
||||||
|
|
||||||
|
- `pkg/security/` — **puro**: solo tipos y funciones de transformación. Cero I/O, cero side effects.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
### Fase 1: Tipos y resolver
|
||||||
|
|
||||||
|
- [ ] **1.1** Crear `pkg/security/groups.go`:
|
||||||
|
```go
|
||||||
|
type UserGroup struct { Name string; Members []string }
|
||||||
|
type AgentGroup struct { Name string; Agents []string }
|
||||||
|
```
|
||||||
|
- [ ] **1.2** Crear `pkg/security/policy.go`:
|
||||||
|
```go
|
||||||
|
type Permission struct { UserGroup string; Actions []string }
|
||||||
|
type AgentPolicy struct { AgentGroup string; Permissions []Permission }
|
||||||
|
type SecurityPolicy struct { UserGroups []UserGroup; AgentGroups []AgentGroup; Policies []AgentPolicy }
|
||||||
|
```
|
||||||
|
- [ ] **1.3** Crear `pkg/security/resolver.go` con `ResolveACL(agentID string, p SecurityPolicy) acl.ACL`:
|
||||||
|
- Iterar `p.Policies` para encontrar `AgentPolicy` cuyo `AgentGroup` sea un grupo que contenga `agentID` o `"*"`, o sea directamente el `agentID`
|
||||||
|
- Para cada `AgentPolicy` que aplique, iterar sus `Permissions`
|
||||||
|
- Resolver `Permission.UserGroup` a los `Members` del grupo correspondiente
|
||||||
|
- Construir `[]acl.Role` y devolver `acl.ACL` via `acl.FromRoles()` (verificar que esta función existe; si no, añadirla a `pkg/acl/`)
|
||||||
|
- [ ] **1.4** Soporte wildcard: `AgentGroup.Agents = ["*"]` → aplica la policy a cualquier agentID; `UserGroup.Members = ["*"]` → rol sin restricción de usuario
|
||||||
|
- [ ] **1.5** Políticas acumulativas: si un agente aparece en múltiples grupos, sus permisos se acumulan (unión de roles)
|
||||||
|
|
||||||
|
### Fase 2: Tests
|
||||||
|
|
||||||
|
- [ ] **2.1** Test: sin política → ACL vacía (todo permitido, comportamiento actual de acl.Empty())
|
||||||
|
- [ ] **2.2** Test: agente en grupo → recibe los permisos del grupo
|
||||||
|
- [ ] **2.3** Test: agente NO en ningún grupo → ACL vacía
|
||||||
|
- [ ] **2.4** Test: wildcard de agente `"*"` → todos los agentes reciben los permisos
|
||||||
|
- [ ] **2.5** Test: wildcard de usuario `"*"` → todos los usuarios reciben la acción
|
||||||
|
- [ ] **2.6** Test: múltiples grupos que incluyen al agente → permisos acumulados (unión)
|
||||||
|
- [ ] **2.7** Test: agente referenciado directamente por ID en `AgentPolicy.AgentGroup` (sin definir grupo) → recibe permisos
|
||||||
|
|
||||||
|
### Fase 3: Cleanup
|
||||||
|
|
||||||
|
- [ ] **3.1** `go build -tags goolm ./...` compila sin errores
|
||||||
|
- [ ] **3.2** `go test -tags goolm ./pkg/security/...` pasa
|
||||||
|
|
||||||
|
## Ejemplo de uso
|
||||||
|
|
||||||
|
```go
|
||||||
|
policy := security.SecurityPolicy{
|
||||||
|
UserGroups: []security.UserGroup{
|
||||||
|
{Name: "admins", Members: []string{"@alice:matrix.org"}},
|
||||||
|
{Name: "everyone", Members: []string{"*"}},
|
||||||
|
},
|
||||||
|
AgentGroups: []security.AgentGroup{
|
||||||
|
{Name: "all", Agents: []string{"*"}},
|
||||||
|
},
|
||||||
|
Policies: []security.AgentPolicy{
|
||||||
|
{
|
||||||
|
AgentGroup: "all",
|
||||||
|
Permissions: []security.Permission{
|
||||||
|
{UserGroup: "admins", Actions: []string{"*"}},
|
||||||
|
{UserGroup: "everyone", Actions: []string{"ask"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
acl := security.ResolveACL("assistant-bot", policy)
|
||||||
|
acl.CanDo("@alice:matrix.org", "tool:ssh_command") // true (admin → "*")
|
||||||
|
acl.CanDo("@unknown:matrix.org", "ask") // true (everyone → "ask")
|
||||||
|
acl.CanDo("@unknown:matrix.org", "command:deploy") // false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisiones de diseño
|
||||||
|
|
||||||
|
- **No reemplazar pkg/acl/**: este paquete produce `acl.ACL`, no lo sustituye. Máxima reutilización.
|
||||||
|
- **AgentPolicy.AgentGroup acepta nombre de grupo O ID directo de agente**: permite asignar permisos a un agente individual sin crear un grupo de un solo elemento.
|
||||||
|
- **Unión de permisos entre grupos**: si un agente está en `assistants` y en `all`, recibe la unión de sus permisos. Seguro: siempre da más acceso, nunca menos de lo esperado.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- `pkg/acl/` compilando (completado en issue 0010)
|
||||||
|
|
||||||
|
## Riesgos
|
||||||
|
|
||||||
|
- **acl.FromRoles() puede no existir**: si `pkg/acl/` solo expone `FromMap(map[string]RoleDef)`, añadir `FromRoles([]Role) ACL` en ese paquete como parte de esta tarea. Es una adición mínima.
|
||||||
@@ -6,6 +6,11 @@ type RoleDef struct {
|
|||||||
Actions []string
|
Actions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FromRoles builds an ACL directly from a slice of Role values.
|
||||||
|
func FromRoles(roles []Role) ACL {
|
||||||
|
return ACL{roles: roles}
|
||||||
|
}
|
||||||
|
|
||||||
// FromMap builds an ACL from a map of role name → RoleDef.
|
// FromMap builds an ACL from a map of role name → RoleDef.
|
||||||
// This is the primary constructor used from the runtime.
|
// This is the primary constructor used from the runtime.
|
||||||
func FromMap(roles map[string]RoleDef) ACL {
|
func FromMap(roles map[string]RoleDef) ACL {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Package security provides pure types and functions for centralized permission management.
|
||||||
|
// No I/O, no side effects — only data transformations.
|
||||||
|
package security
|
||||||
|
|
||||||
|
// UserGroup is a named set of Matrix user IDs.
|
||||||
|
// Members may contain "*" to represent all users.
|
||||||
|
type UserGroup struct {
|
||||||
|
Name string
|
||||||
|
Members []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentGroup is a named set of agent IDs.
|
||||||
|
// Agents may contain "*" to represent all agents.
|
||||||
|
type AgentGroup struct {
|
||||||
|
Name string
|
||||||
|
Agents []string
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
// Permission grants a set of actions to all members of a UserGroup.
|
||||||
|
type Permission struct {
|
||||||
|
UserGroup string
|
||||||
|
Actions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentPolicy assigns a set of permissions to all agents in an AgentGroup.
|
||||||
|
// AgentGroup may be a group name defined in SecurityPolicy.AgentGroups,
|
||||||
|
// or a direct agent ID (without defining a group).
|
||||||
|
type AgentPolicy struct {
|
||||||
|
AgentGroup string
|
||||||
|
Permissions []Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityPolicy is the top-level pure data structure that describes
|
||||||
|
// who can do what across which agents.
|
||||||
|
type SecurityPolicy struct {
|
||||||
|
UserGroups []UserGroup
|
||||||
|
AgentGroups []AgentGroup
|
||||||
|
Policies []AgentPolicy
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import "github.com/enmanuel/agents/pkg/acl"
|
||||||
|
|
||||||
|
// ResolveACL computes the ACL for a given agentID from a SecurityPolicy.
|
||||||
|
//
|
||||||
|
// Resolution rules:
|
||||||
|
// - An AgentPolicy applies to agentID if its AgentGroup field is:
|
||||||
|
// (a) a group name in p.AgentGroups whose Agents list contains agentID or "*", or
|
||||||
|
// (b) directly equal to agentID (individual assignment without a named group).
|
||||||
|
// - If multiple AgentPolicies apply, their permissions are accumulated (union).
|
||||||
|
// - For each Permission, the UserGroup is resolved to members in p.UserGroups.
|
||||||
|
// A UserGroup with Members = ["*"] grants the actions to all users.
|
||||||
|
// - If no policy applies, an empty ACL is returned (open access per acl semantics).
|
||||||
|
func ResolveACL(agentID string, p SecurityPolicy) acl.ACL {
|
||||||
|
// Build a lookup: group name → members.
|
||||||
|
userGroupMembers := make(map[string][]string, len(p.UserGroups))
|
||||||
|
for _, ug := range p.UserGroups {
|
||||||
|
userGroupMembers[ug.Name] = ug.Members
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all roles from every AgentPolicy that applies to this agent.
|
||||||
|
var roles []acl.Role
|
||||||
|
for _, ap := range p.Policies {
|
||||||
|
if !agentPolicyApplies(agentID, ap.AgentGroup, p.AgentGroups) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, perm := range ap.Permissions {
|
||||||
|
members := resolveMembers(perm.UserGroup, userGroupMembers)
|
||||||
|
roles = append(roles, acl.Role{
|
||||||
|
Name: perm.UserGroup,
|
||||||
|
Users: members,
|
||||||
|
Actions: perm.Actions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acl.FromRoles(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentPolicyApplies returns true if an AgentPolicy with the given agentGroupRef
|
||||||
|
// should apply to agentID.
|
||||||
|
func agentPolicyApplies(agentID, agentGroupRef string, groups []AgentGroup) bool {
|
||||||
|
// Try to find a named group first.
|
||||||
|
for _, ag := range groups {
|
||||||
|
if ag.Name != agentGroupRef {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range ag.Agents {
|
||||||
|
if a == "*" || a == agentID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false // group found but agent not in it
|
||||||
|
}
|
||||||
|
// No matching group found — treat agentGroupRef as a direct agent ID.
|
||||||
|
return agentGroupRef == agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMembers returns the member list for a user group name.
|
||||||
|
// If the group is not defined, it falls back to treating the name as a literal user ID.
|
||||||
|
func resolveMembers(userGroupName string, lookup map[string][]string) []string {
|
||||||
|
if members, ok := lookup[userGroupName]; ok {
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
// Fallback: treat as a direct user ID.
|
||||||
|
return []string{userGroupName}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package security_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/enmanuel/agents/pkg/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
|
||||||
|
func makePolicy(userGroups []security.UserGroup, agentGroups []security.AgentGroup, policies []security.AgentPolicy) security.SecurityPolicy {
|
||||||
|
return security.SecurityPolicy{
|
||||||
|
UserGroups: userGroups,
|
||||||
|
AgentGroups: agentGroups,
|
||||||
|
Policies: policies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.1 — sin política → ACL vacía → todo permitido (acl.Empty() == true)
|
||||||
|
func TestResolveACL_EmptyPolicy(t *testing.T) {
|
||||||
|
a := security.ResolveACL("assistant-bot", security.SecurityPolicy{})
|
||||||
|
if !a.Empty() {
|
||||||
|
t.Fatal("expected empty ACL for empty policy")
|
||||||
|
}
|
||||||
|
if !a.CanDo("@anyone:matrix.org", "anything") {
|
||||||
|
t.Fatal("empty ACL should allow everything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 — agente en grupo → recibe los permisos del grupo
|
||||||
|
func TestResolveACL_AgentInGroup(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
|
||||||
|
[]security.AgentGroup{{Name: "bots", Agents: []string{"assistant-bot"}}},
|
||||||
|
[]security.AgentPolicy{{
|
||||||
|
AgentGroup: "bots",
|
||||||
|
Permissions: []security.Permission{
|
||||||
|
{UserGroup: "admins", Actions: []string{"ask"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
a := security.ResolveACL("assistant-bot", p)
|
||||||
|
if a.Empty() {
|
||||||
|
t.Fatal("ACL should not be empty")
|
||||||
|
}
|
||||||
|
if !a.CanDo("@alice:matrix.org", "ask") {
|
||||||
|
t.Fatal("alice should be able to ask")
|
||||||
|
}
|
||||||
|
if a.CanDo("@bob:matrix.org", "ask") {
|
||||||
|
t.Fatal("bob should not be able to ask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.3 — agente NO en ningún grupo → ACL vacía
|
||||||
|
func TestResolveACL_AgentNotInGroup(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
|
||||||
|
[]security.AgentGroup{{Name: "bots", Agents: []string{"other-bot"}}},
|
||||||
|
[]security.AgentPolicy{{
|
||||||
|
AgentGroup: "bots",
|
||||||
|
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"ask"}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
a := security.ResolveACL("assistant-bot", p)
|
||||||
|
if !a.Empty() {
|
||||||
|
t.Fatal("expected empty ACL for agent not in any group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.4 — wildcard de agente "*" → todos los agentes reciben los permisos
|
||||||
|
func TestResolveACL_AgentWildcard(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{{Name: "everyone", Members: []string{"*"}}},
|
||||||
|
[]security.AgentGroup{{Name: "all", Agents: []string{"*"}}},
|
||||||
|
[]security.AgentPolicy{{
|
||||||
|
AgentGroup: "all",
|
||||||
|
Permissions: []security.Permission{{UserGroup: "everyone", Actions: []string{"ask"}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
for _, agentID := range []string{"assistant-bot", "asistente-2", "any-random-bot"} {
|
||||||
|
a := security.ResolveACL(agentID, p)
|
||||||
|
if a.Empty() {
|
||||||
|
t.Fatalf("ACL for %q should not be empty", agentID)
|
||||||
|
}
|
||||||
|
if !a.CanDo("@whoever:matrix.org", "ask") {
|
||||||
|
t.Fatalf("any user should be able to ask via agent %q", agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 — wildcard de usuario "*" → todos los usuarios reciben la acción
|
||||||
|
func TestResolveACL_UserWildcard(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{{Name: "everyone", Members: []string{"*"}}},
|
||||||
|
[]security.AgentGroup{{Name: "bots", Agents: []string{"assistant-bot"}}},
|
||||||
|
[]security.AgentPolicy{{
|
||||||
|
AgentGroup: "bots",
|
||||||
|
Permissions: []security.Permission{{UserGroup: "everyone", Actions: []string{"ask"}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
a := security.ResolveACL("assistant-bot", p)
|
||||||
|
for _, user := range []string{"@alice:matrix.org", "@bob:example.com", "@unknown:other.server"} {
|
||||||
|
if !a.CanDo(user, "ask") {
|
||||||
|
t.Fatalf("user %q should be able to ask (wildcard user group)", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.6 — múltiples grupos que incluyen al agente → permisos acumulados
|
||||||
|
func TestResolveACL_AccumulatedPermissions(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{
|
||||||
|
{Name: "admins", Members: []string{"@alice:matrix.org"}},
|
||||||
|
{Name: "users", Members: []string{"@bob:matrix.org"}},
|
||||||
|
},
|
||||||
|
[]security.AgentGroup{
|
||||||
|
{Name: "premium", Agents: []string{"assistant-bot"}},
|
||||||
|
{Name: "all", Agents: []string{"*"}},
|
||||||
|
},
|
||||||
|
[]security.AgentPolicy{
|
||||||
|
{
|
||||||
|
AgentGroup: "premium",
|
||||||
|
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"deploy"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AgentGroup: "all",
|
||||||
|
Permissions: []security.Permission{{UserGroup: "users", Actions: []string{"ask"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a := security.ResolveACL("assistant-bot", p)
|
||||||
|
if !a.CanDo("@alice:matrix.org", "deploy") {
|
||||||
|
t.Fatal("alice should be able to deploy (via premium group)")
|
||||||
|
}
|
||||||
|
if !a.CanDo("@bob:matrix.org", "ask") {
|
||||||
|
t.Fatal("bob should be able to ask (via all group)")
|
||||||
|
}
|
||||||
|
if a.CanDo("@bob:matrix.org", "deploy") {
|
||||||
|
t.Fatal("bob should not be able to deploy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.7 — agente referenciado directamente por ID en AgentPolicy.AgentGroup → recibe permisos
|
||||||
|
func TestResolveACL_DirectAgentID(t *testing.T) {
|
||||||
|
p := makePolicy(
|
||||||
|
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
|
||||||
|
nil, // no named groups
|
||||||
|
[]security.AgentPolicy{{
|
||||||
|
AgentGroup: "assistant-bot", // direct agent ID, no group defined
|
||||||
|
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"*"}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
a := security.ResolveACL("assistant-bot", p)
|
||||||
|
if !a.CanDo("@alice:matrix.org", "anything") {
|
||||||
|
t.Fatal("alice should have full access via direct agent ID assignment")
|
||||||
|
}
|
||||||
|
// other agents should not be affected
|
||||||
|
b := security.ResolveACL("asistente-2", p)
|
||||||
|
if !b.Empty() {
|
||||||
|
t.Fatal("asistente-2 should not receive permissions from direct assignment to assistant-bot")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user