Files
egutierrez 22219a6a6e chore: auto-commit (1 archivos)
- admin_service.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:16 +02:00

419 lines
11 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"fn-registry/projects/element_agents/apps/matrix_admin_panel/internal/infra"
)
// Constants — hardcoded for issue 0163 MVP. Operator-configurable later via settings UI.
const (
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
masIssuer = "https://auth-af2f3d.organic-machine.com/"
masClientID = "5SFD2SWA394DXRVJFTREAMY6J6"
loopbackPort = 8766
keyringServiceName = "fn_registry.matrix_admin_panel"
oidcTimeoutSeconds = 300
adminTokenPrefix = "admin_token:"
)
var defaultScopes = []string{
"openid",
"urn:matrix:org.matrix.msc2967.client:api:*",
}
// AdminService is bound to the Wails frontend.
type AdminService struct {
ctx context.Context
mu sync.Mutex
store *infra.KeyringTokenStore
adminToken string
loggedUser string
}
func NewAdminService() *AdminService {
return &AdminService{
store: infra.NewKeyringTokenStore(keyringServiceName),
}
}
func (s *AdminService) SetContext(ctx context.Context) {
s.ctx = ctx
}
// SessionView is the safe-to-send JSON for the frontend (no tokens).
type SessionView struct {
UserID string `json:"user_id"`
HomeserverURL string `json:"homeserver_url"`
HasOIDCToken bool `json:"has_oidc_token"`
HasAdminToken bool `json:"has_admin_token"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
// Returns the OIDC subject (user identifier as known by MAS) for session lookup.
func (s *AdminService) Login() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg := infra.MasOidcLoopbackConfig{
Issuer: masIssuer,
ClientID: masClientID,
Scopes: defaultScopes,
LoopbackPort: loopbackPort,
OpenBrowser: true,
TimeoutSeconds: oidcTimeoutSeconds,
}
res, err := infra.MasOidcLoopback(cfg)
if err != nil {
return "", fmt.Errorf("oidc: %w", err)
}
// Resolve user_id via /whoami so we have a stable handle.
userID, _, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
tok := infra.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
UserID: userID,
HomeserverURL: homeserverURL,
Issuer: masIssuer,
ClientID: masClientID,
}
if res.ExpiresIn > 0 {
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
}
if err := s.store.Save(userID, tok); err != nil {
return "", fmt.Errorf("keyring save: %w", err)
}
s.loggedUser = userID
// Try to load a previously saved admin token for this user.
if adminTok, err := s.store.Load(adminTokenPrefix + userID); err == nil {
s.adminToken = adminTok.AccessToken
}
return userID, nil
}
// GetSession returns the persisted session for the given user_id.
func (s *AdminService) GetSession(userID string) (*SessionView, error) {
if userID == "" {
return nil, errors.New("user_id required")
}
tok, err := s.store.Load(userID)
if err != nil {
if errors.Is(err, infra.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("keyring load: %w", err)
}
// Re-attach admin token in memory on restart.
s.mu.Lock()
if s.adminToken == "" {
if adminTok, err := s.store.Load(adminTokenPrefix + userID); err == nil {
s.adminToken = adminTok.AccessToken
}
}
s.loggedUser = userID
hasAdmin := s.adminToken != ""
s.mu.Unlock()
view := &SessionView{
UserID: tok.UserID,
HomeserverURL: tok.HomeserverURL,
HasOIDCToken: tok.AccessToken != "",
HasAdminToken: hasAdmin,
}
if !tok.ExpiresAt.IsZero() {
view.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339)
}
return view, nil
}
// Logout deletes the persisted OIDC token + admin token for the given user_id.
func (s *AdminService) Logout(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if userID == "" {
return errors.New("user_id required")
}
_ = s.store.Delete(adminTokenPrefix + userID)
s.adminToken = ""
s.loggedUser = ""
return s.store.Delete(userID)
}
// SetAdminToken validates the provided Synapse admin access_token against the admin API
// and persists it on success. The token is NOT the MAS OIDC token — it must belong to a
// user with `admin: true` in Synapse.
func (s *AdminService) SetAdminToken(token string) error {
s.mu.Lock()
defer s.mu.Unlock()
token = strings.TrimSpace(token)
if token == "" {
return errors.New("admin token is empty")
}
if s.loggedUser == "" {
return errors.New("must login first")
}
// Probe the admin API: GET /_synapse/admin/v1/users/{self} requires admin:true.
// We use the OIDC-logged user as probe target — the lookup always works for admins.
client := infra.NewSynapseAdminClient(homeserverURL, token)
if _, err := client.GetUser(s.context(), s.loggedUser); err != nil {
return fmt.Errorf("admin token invalid: %w", err)
}
s.adminToken = token
// Persist (reuse Token struct, only AccessToken matters).
if err := s.store.Save(adminTokenPrefix+s.loggedUser, infra.Token{
AccessToken: token,
UserID: s.loggedUser,
HomeserverURL: homeserverURL,
}); err != nil {
return fmt.Errorf("keyring save admin token: %w", err)
}
return nil
}
// adminClient returns a configured SynapseAdminClient or an error if admin token not set.
func (s *AdminService) adminClient() (*infra.SynapseAdminClient, error) {
s.mu.Lock()
tok := s.adminToken
s.mu.Unlock()
if tok == "" {
return nil, errors.New("admin token not set; call SetAdminToken first")
}
return infra.NewSynapseAdminClient(homeserverURL, tok), nil
}
func (s *AdminService) context() context.Context {
if s.ctx != nil {
return s.ctx
}
return context.Background()
}
// --- Args/Results for Wails bindings (JSON-safe, no interfaces) ---
type AdminUserArg struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Admin bool `json:"admin"`
Deactivated bool `json:"deactivated"`
IsGuest bool `json:"is_guest"`
CreationTs int64 `json:"creation_ts"`
LastSeenTs int64 `json:"last_seen_ts"`
}
type ListUsersFilterArg struct {
From int `json:"from"`
Limit int `json:"limit"`
SearchTerm string `json:"search_term"`
DeactivatedSet bool `json:"deactivated_set"` // true => apply deactivated filter
Deactivated bool `json:"deactivated"`
AdminsSet bool `json:"admins_set"` // true => apply admins filter
Admins bool `json:"admins"`
}
type ListUsersResultArg struct {
Users []AdminUserArg `json:"users"`
TotalCount int `json:"total_count"`
NextToken int `json:"next_token"` // -1 if no more pages
}
type AdminRoomArg struct {
RoomID string `json:"room_id"`
Name string `json:"name"`
CanonicalAlias string `json:"canonical_alias"`
JoinedMembers int `json:"joined_members"`
JoinedLocal int `json:"joined_local"`
Version string `json:"version"`
Encrypted bool `json:"encrypted"`
Federatable bool `json:"federatable"`
Public bool `json:"public"`
}
type ListRoomsResultArg struct {
Rooms []AdminRoomArg `json:"rooms"`
TotalCount int `json:"total_count"`
NextToken int `json:"next_token"` // -1 if no more pages
}
type AdminDeviceArg struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
LastSeenIP string `json:"last_seen_ip"`
LastSeenTs int64 `json:"last_seen_ts"`
}
func toUserArg(u infra.AdminUser) AdminUserArg {
return AdminUserArg{
UserID: u.UserID,
DisplayName: u.DisplayName,
AvatarURL: u.AvatarURL,
Admin: u.Admin,
Deactivated: u.Deactivated,
IsGuest: u.IsGuest,
CreationTs: u.CreationTs,
LastSeenTs: u.LastSeenTs,
}
}
func toRoomArg(r infra.AdminRoom) AdminRoomArg {
return AdminRoomArg{
RoomID: r.RoomID,
Name: r.Name,
CanonicalAlias: r.CanonicalAlias,
JoinedMembers: r.JoinedMembers,
JoinedLocal: r.JoinedLocal,
Version: r.Version,
Encrypted: r.Encrypted,
Federatable: r.Federatable,
Public: r.Public,
}
}
// --- Users ---
func (s *AdminService) ListUsers(f ListUsersFilterArg) (*ListUsersResultArg, error) {
c, err := s.adminClient()
if err != nil {
return nil, err
}
filter := infra.ListUsersFilter{
From: f.From,
Limit: f.Limit,
SearchTerm: f.SearchTerm,
}
if f.DeactivatedSet {
v := f.Deactivated
filter.Deactivated = &v
}
if f.AdminsSet {
v := f.Admins
filter.Admins = &v
}
res, err := c.ListUsers(s.context(), filter)
if err != nil {
return nil, err
}
out := &ListUsersResultArg{
TotalCount: res.TotalCount,
NextToken: -1,
Users: make([]AdminUserArg, 0, len(res.Users)),
}
if res.NextToken != nil {
out.NextToken = *res.NextToken
}
for _, u := range res.Users {
out.Users = append(out.Users, toUserArg(u))
}
return out, nil
}
func (s *AdminService) DeactivateUser(userID string, erase bool) error {
c, err := s.adminClient()
if err != nil {
return err
}
return c.DeactivateUser(s.context(), userID, erase)
}
func (s *AdminService) ResetUserPassword(userID, newPassword string, logoutDevices bool) error {
if strings.TrimSpace(newPassword) == "" {
return errors.New("new_password is empty")
}
c, err := s.adminClient()
if err != nil {
return err
}
return c.ResetPassword(s.context(), userID, newPassword, logoutDevices)
}
func (s *AdminService) GetUserDevices(userID string) ([]AdminDeviceArg, error) {
c, err := s.adminClient()
if err != nil {
return nil, err
}
devices, err := c.ListUserDevices(s.context(), userID)
if err != nil {
return nil, err
}
out := make([]AdminDeviceArg, 0, len(devices))
for _, d := range devices {
out = append(out, AdminDeviceArg{
DeviceID: d.DeviceID,
DisplayName: d.DisplayName,
LastSeenIP: d.LastSeenIP,
LastSeenTs: d.LastSeenTs,
})
}
return out, nil
}
// --- Rooms ---
func (s *AdminService) ListRooms(from, limit int, search string) (*ListRoomsResultArg, error) {
c, err := s.adminClient()
if err != nil {
return nil, err
}
rooms, total, next, err := c.ListRooms(s.context(), from, limit, search)
if err != nil {
return nil, err
}
out := &ListRoomsResultArg{
TotalCount: total,
NextToken: -1,
Rooms: make([]AdminRoomArg, 0, len(rooms)),
}
if next != nil {
out.NextToken = *next
}
for _, r := range rooms {
out.Rooms = append(out.Rooms, toRoomArg(r))
}
return out, nil
}
func (s *AdminService) DeleteRoom(roomID, reason string, purge, block bool) (string, error) {
c, err := s.adminClient()
if err != nil {
return "", err
}
return c.DeleteRoom(s.context(), roomID, reason, purge, block)
}
// HTTPStatusCheck is a small helper exposed mostly for diagnostics: hits Synapse health.
func (s *AdminService) Ping() (int, error) {
req, err := http.NewRequestWithContext(s.context(), http.MethodGet, homeserverURL+"/_matrix/client/versions", nil)
if err != nil {
return 0, err
}
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}