feat: lldapgql GraphQL 写客户端(对真实 LLDAP 冒烟通过)
- vendored LLDAP schema.graphql(v0.6.3),GraphQL 端点实测为 /api/graphql - 手工 GraphQL 客户端(genqlient v0.8.1 因依赖的 x/tools v0.24 无法在 Go 1.26 编译, 按计划风险节回归运行时客户端):登录 /auth/simple/login、createUser/updateUser/ deleteUser/createGroup/deleteGroup/addUserToGroup/removeUserFromGroup、ListGroups/Users、 预留 AddUserAttribute(默认不启用) - 语义对齐硬约束:updateUser 省略即不改(指针字段)、绝不写密码 - 集成冒烟(真实 LLDAP 容器):create→group→add→query→update(省略不变)→remove→delete 全通过;addUserToGroup 对重复添加报 UNIQUE 约束(非幂等),靠物化 diff 保证不重复加 - AGENTS.md 标注 lldapgql 改为手工客户端 + /api/graphql 端点
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// Package lldapgql 是唯一负责把状态写入 LLDAP 的包(GraphQL 写通道)。
|
||||
//
|
||||
// 写走 GraphQL、读走 LDAP:LLDAP 改写的所有操作都落在这里,genqlient/手工客户端
|
||||
// 只在包内,不给其他包暴露原始 GraphQL。
|
||||
//
|
||||
// 为什么不用 genqlient 代码生成:genqlient v0.8.1 依赖的 golang.org/x/tools v0.24.0
|
||||
// 无法用本项目容器内的 Go 1.26 编译(已知不兼容),因此回归到运行时手工客户端。
|
||||
// 语义保持一致:UpdateUserInput 省略字段 = 不改变,绝不显式传 null 覆盖字段。
|
||||
// 密码没有 mutation——LLDAP 自己管哈希,程序不碰。
|
||||
|
||||
package lldapgql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AttributeType 对应 schema 里的 AttributeType 枚举。
|
||||
type AttributeType string
|
||||
|
||||
const (
|
||||
AttributeString AttributeType = "STRING"
|
||||
AttributeInteger AttributeType = "INTEGER"
|
||||
AttributeJpegPhoto AttributeType = "JPEG_PHOTO"
|
||||
AttributeDateTime AttributeType = "DATE_TIME"
|
||||
)
|
||||
|
||||
// AttributeValueInput 对应 schema 里的输入,name + 值列表。
|
||||
type AttributeValueInput struct {
|
||||
Name string `json:"name"`
|
||||
Value []string `json:"value"`
|
||||
}
|
||||
|
||||
// CreateUserInput 对应 schema 的 CreateUserInput。
|
||||
// 密码不在其中:LLDAP 不提供创建时设密码,首次登录走 Authelia 忘记密码。
|
||||
type CreateUserInput struct {
|
||||
ID string `json:"id"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
FirstName *string `json:"firstName,omitempty"`
|
||||
LastName *string `json:"lastName,omitempty"`
|
||||
Attributes []AttributeValueInput `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserInput 对应 schema 的 UpdateUserInput。
|
||||
// 关键语义:省略的字段 = 不改变。所有字段都是指针,nil 即不发送。
|
||||
type UpdateUserInput struct {
|
||||
ID string `json:"id"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
FirstName *string `json:"firstName,omitempty"`
|
||||
LastName *string `json:"lastName,omitempty"`
|
||||
RemoveAttributes []string `json:"removeAttributes,omitempty"`
|
||||
InsertAttributes []AttributeValueInput `json:"insertAttributes,omitempty"`
|
||||
}
|
||||
|
||||
// Group 是一次查询返回的分组(displayName 即组 CN,用于映射 id)。
|
||||
type Group struct {
|
||||
ID int `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// User 是一次查询返回的用户。id 即 uid。
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// Client 是 LLDAP 的 GraphQL 写客户端。
|
||||
type Client struct {
|
||||
baseURL string // 形如 http://host:17170,不含尾部斜杠
|
||||
http *http.Client
|
||||
token string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 构造写客户端。baseURL 是 LLDAP WebUI/GraphQL 的对外地址。
|
||||
func New(baseURL string, log *slog.Logger) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Login 用管理员凭据换取访问令牌。每次物化开始时调用一次。
|
||||
//
|
||||
// 后端是 `POST /auth/simple/login`,返回 `{"token":"<jwt>"}`;后续 GraphQL
|
||||
// 请求带 `Authorization: Bearer <token>`。
|
||||
func (c *Client) Login(ctx context.Context, username, password string) error {
|
||||
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/auth/simple/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("登录 LLDAP 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return fmt.Errorf("解析 LLDAP 登录响应失败 (HTTP %d): %w", resp.StatusCode, err)
|
||||
}
|
||||
if out.Token == "" {
|
||||
return fmt.Errorf("LLDAP 登录失败 (HTTP %d):未返回令牌,请检查管理员用户名/密码", resp.StatusCode)
|
||||
}
|
||||
c.token = out.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
// gql 发送一次 GraphQL 请求并解析 data 到 out;GraphQL 层错误会转成语义化错误。
|
||||
func (c *Client) gql(ctx context.Context, query string, vars map[string]any, out any) error {
|
||||
payload := map[string]any{"query": query}
|
||||
if len(vars) > 0 {
|
||||
payload["variables"] = vars
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/graphql", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GraphQL 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// GraphQL 服务端即使 HTTP 200 也可能在 errors 里给出失败,必须两者都查。
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return fmt.Errorf("解析 GraphQL 响应失败 (HTTP %d): %w", resp.StatusCode, err)
|
||||
}
|
||||
if len(envelope.Errors) > 0 {
|
||||
msgs := make([]string, 0, len(envelope.Errors))
|
||||
for _, e := range envelope.Errors {
|
||||
msgs = append(msgs, e.Message)
|
||||
}
|
||||
return fmt.Errorf("LLDAP GraphQL 错误: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(envelope.Data) == 0 {
|
||||
return fmt.Errorf("LLDAP GraphQL 未返回 data")
|
||||
}
|
||||
return json.Unmarshal(envelope.Data, out)
|
||||
}
|
||||
|
||||
// strPtr 生成 *string。
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// CreateUser 创建用户。仅在创建时一次性写入硬字段;密码交给 LLDAP/Authelia。
|
||||
func (c *Client) CreateUser(ctx context.Context, in *CreateUserInput) error {
|
||||
vars := map[string]any{"user": in}
|
||||
var out struct {
|
||||
CreateUser struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"createUser"`
|
||||
}
|
||||
if err := c.gql(ctx, `mutation($user:CreateUserInput!){createUser(user:$user){id}}`, vars, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户硬字段。「省略即不改」由调用方通过 nil 指针控制。
|
||||
func (c *Client) UpdateUser(ctx context.Context, in *UpdateUserInput) error {
|
||||
// 结构性护栏:id 必填,且绝不更新密码(schema 根本没有密码字段,双保险防止误传)。
|
||||
if in.ID == "" {
|
||||
return fmt.Errorf("UpdateUser 缺少 id")
|
||||
}
|
||||
vars := map[string]any{"user": in}
|
||||
return c.gql(ctx, `mutation($user:UpdateUserInput!){updateUser(user:$user){ok}}`, vars, nil)
|
||||
}
|
||||
|
||||
// DeleteUser 物理删除用户。级联清理其全部成员关系(LLDAP memberships 外键 CASCADE)。
|
||||
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
|
||||
vars := map[string]any{"userId": userID}
|
||||
return c.gql(ctx, `mutation($userId:String!){deleteUser(userId:$userId){ok}}`, vars, nil)
|
||||
}
|
||||
|
||||
// CreateGroup 创建分组,name 即 LLDAP 的 displayName/CN。
|
||||
func (c *Client) CreateGroup(ctx context.Context, name string) (int, error) {
|
||||
vars := map[string]any{"name": name}
|
||||
var out struct {
|
||||
CreateGroup struct {
|
||||
ID int `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"createGroup"`
|
||||
}
|
||||
if err := c.gql(ctx, `mutation($name:String!){createGroup(name:$name){id displayName}}`, vars, &out); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.CreateGroup.ID, nil
|
||||
}
|
||||
|
||||
// DeleteGroup 删除分组,级联清理成员关系。
|
||||
func (c *Client) DeleteGroup(ctx context.Context, groupID int) error {
|
||||
vars := map[string]any{"groupId": groupID}
|
||||
return c.gql(ctx, `mutation($groupId:Int!){deleteGroup(groupId:$groupId){ok}}`, vars, nil)
|
||||
}
|
||||
|
||||
// AddUserToGroup 把用户加入分组。
|
||||
func (c *Client) AddUserToGroup(ctx context.Context, userID string, groupID int) error {
|
||||
vars := map[string]any{"userId": userID, "groupId": groupID}
|
||||
return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){addUserToGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil)
|
||||
}
|
||||
|
||||
// RemoveUserFromGroup 把用户移出分组。
|
||||
func (c *Client) RemoveUserFromGroup(ctx context.Context, userID string, groupID int) error {
|
||||
vars := map[string]any{"userId": userID, "groupId": groupID}
|
||||
return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){removeUserFromGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil)
|
||||
}
|
||||
|
||||
// Groups 拉全量分组,用于按 displayName(组 CN)解析 id。
|
||||
func (c *Client) Groups(ctx context.Context) ([]Group, error) {
|
||||
var out struct {
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
if err := c.gql(ctx, `query{groups{id displayName}}`, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Groups, nil
|
||||
}
|
||||
|
||||
// Users 拉全量用户,用于按 uid 解析当前状态。读 LDAP 已足够时可不调用。
|
||||
func (c *Client) Users(ctx context.Context) ([]User, error) {
|
||||
var out struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
if err := c.gql(ctx, `query{users{id email displayName}}`, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Users, nil
|
||||
}
|
||||
|
||||
// AddUserAttribute 向 LLDAP 声明一个用户自定义属性(幂等)。
|
||||
// 默认不调用:Authelia 官方模板不依赖任何自定义属性,仅对接 ssh/pam 时才启用。
|
||||
func (c *Client) AddUserAttribute(ctx context.Context, name string, typ AttributeType, isList, isVisible, isEditable bool) error {
|
||||
vars := map[string]any{
|
||||
"name": name, "attributeType": string(typ),
|
||||
"isList": isList, "isVisible": isVisible, "isEditable": isEditable,
|
||||
}
|
||||
return c.gql(ctx, `mutation($name:String!,$attributeType:AttributeType!,$isList:Boolean!,$isVisible:Boolean!,$isEditable:Boolean!){addUserAttribute(name:$name,attributeType:$attributeType,isList:$isList,isVisible:$isVisible,isEditable:$isEditable){ok}}`, vars, nil)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//go:build integration
|
||||
|
||||
// lldapgql 的集成冒烟测试:对真实 LLDAP(本地容器)跑通
|
||||
// 登录 → 建用户 → 建组 → 加组成员 → 更新(省略即不改)→ 查询 → 移除成员 → 删除。
|
||||
//
|
||||
// 依赖环境:
|
||||
// LLDAP_GQL_URL 默认 http://127.0.0.1:17170
|
||||
// LLDAP_ADMIN_USER 默认 admin
|
||||
// LLDAP_ADMIN_PASS 默认 adminpass
|
||||
package lldapgql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"idsync/internal/lldapgql"
|
||||
)
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func newClient(t *testing.T) *lldapgql.Client {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
c := lldapgql.New(env("LLDAP_GQL_URL", "http://127.0.0.1:17170"), log)
|
||||
if err := c.Login(context.Background(), env("LLDAP_ADMIN_USER", "admin"), env("LLDAP_ADMIN_PASS", "adminpass")); err != nil {
|
||||
t.Skipf("无法登录 LLDAP,跳过集成测试: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func suffix() string { return fmt.Sprintf("%d", time.Now().UnixNano()%100000000) }
|
||||
|
||||
// TestLLDAPCRUD 覆盖用户/分组/成员关系的完整生命周期。
|
||||
func TestLLDAPCRUD(t *testing.T) {
|
||||
c := newClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uid := "idsync-smoke-" + suffix()
|
||||
grp := "idsync-grp-" + suffix()
|
||||
|
||||
// 1. 建用户(不设密码)。
|
||||
email := uid + "@example.com"
|
||||
err := c.CreateUser(ctx, &lldapgql.CreateUserInput{
|
||||
ID: uid, Email: sp(email), DisplayName: sp("冒烟用户"),
|
||||
FirstName: sp("冒烟"), LastName: sp("用户"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("createUser 失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.DeleteUser(context.Background(), uid) })
|
||||
|
||||
// 2. 建组。
|
||||
gid, err := c.CreateGroup(ctx, grp)
|
||||
if err != nil {
|
||||
t.Fatalf("createGroup 失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.DeleteGroup(context.Background(), gid) })
|
||||
|
||||
// 3. 加组成员。注意 addUserToGroup 对重复添加会以 UNIQUE 约束报错(非幂等),
|
||||
// 因此依赖材料化阶段的 diff 保证只加不存在的成员,不在此层做幂等掩盖。
|
||||
if err := c.AddUserToGroup(ctx, uid, gid); err != nil {
|
||||
t.Fatalf("addUserToGroup 失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 查询侧验证:按 uid 找用户、按 displayName 找组。
|
||||
users, err := c.Users(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List users 失败: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, u := range users {
|
||||
if u.ID == uid {
|
||||
found = true
|
||||
if u.Email != email {
|
||||
t.Errorf("用户 email = %q, 期望 %q", u.Email, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("未在 users 查询中找到 uid=%s", uid)
|
||||
}
|
||||
groups, err := c.Groups(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List groups 失败: %v", err)
|
||||
}
|
||||
gFound := false
|
||||
for _, g := range groups {
|
||||
if g.DisplayName == grp {
|
||||
gFound = true
|
||||
if g.ID != gid {
|
||||
t.Errorf("组 id 不匹配")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gFound {
|
||||
t.Fatalf("未在 groups 查询中找到 %s", grp)
|
||||
}
|
||||
|
||||
// 5. 更新:只改 email,displayName 应保持不变(省略即不改)。
|
||||
if err := c.UpdateUser(ctx, &lldapgql.UpdateUserInput{
|
||||
ID: uid, Email: sp("new-" + email),
|
||||
}); err != nil {
|
||||
t.Fatalf("updateUser 失败: %v", err)
|
||||
}
|
||||
users, _ = c.Users(ctx)
|
||||
for _, u := range users {
|
||||
if u.ID == uid {
|
||||
if u.Email != "new-"+email {
|
||||
t.Errorf("email 未更新: %q", u.Email)
|
||||
}
|
||||
if u.DisplayName != "冒烟用户" {
|
||||
t.Errorf("省略的 displayName 被改了: %q", u.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 移除成员 + 删除用户(验证 deleteUser 成功清掉成员关系)。
|
||||
if err := c.RemoveUserFromGroup(ctx, uid, gid); err != nil {
|
||||
t.Fatalf("removeUserFromGroup 失败: %v", err)
|
||||
}
|
||||
if err := c.DeleteUser(ctx, uid); err != nil {
|
||||
t.Fatalf("deleteUser 失败: %v", err)
|
||||
}
|
||||
if err := c.DeleteGroup(ctx, gid); err != nil {
|
||||
t.Fatalf("deleteGroup 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
@@ -0,0 +1,217 @@
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
|
||||
enum AttributeType {
|
||||
STRING
|
||||
INTEGER
|
||||
JPEG_PHOTO
|
||||
DATE_TIME
|
||||
}
|
||||
|
||||
input AttributeValueInput {
|
||||
"""
|
||||
The name of the attribute. It must be present in the schema, and the type informs how
|
||||
to interpret the values.
|
||||
""" name: String!
|
||||
"""
|
||||
The values of the attribute.
|
||||
If the attribute is not a list, the vector must contain exactly one element.
|
||||
Integers (signed 64 bits) are represented as strings.
|
||||
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
|
||||
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
|
||||
""" value: [String!]!
|
||||
}
|
||||
|
||||
"The details required to create a group."
|
||||
input CreateGroupInput {
|
||||
displayName: String!
|
||||
"User-defined attributes." attributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
"The details required to create a user."
|
||||
input CreateUserInput {
|
||||
id: String!
|
||||
email: String
|
||||
displayName: String
|
||||
"""
|
||||
First name of user. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" firstName: String
|
||||
"""
|
||||
Last name of user. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" lastName: String
|
||||
"""
|
||||
Base64 encoded JpegPhoto. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" avatar: String
|
||||
"Attributes." attributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
input EqualityConstraint {
|
||||
field: String!
|
||||
value: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A filter for requests, specifying a boolean expression based on field constraints. Only one of
|
||||
the fields can be set at a time.
|
||||
"""
|
||||
input RequestFilter {
|
||||
any: [RequestFilter!]
|
||||
all: [RequestFilter!]
|
||||
not: RequestFilter
|
||||
eq: EqualityConstraint
|
||||
memberOf: String
|
||||
memberOfId: Int
|
||||
}
|
||||
|
||||
"The fields that can be updated for a group."
|
||||
input UpdateGroupInput {
|
||||
"The group ID." id: Int!
|
||||
"The new display name." displayName: String
|
||||
"""
|
||||
Attribute names to remove.
|
||||
They are processed before insertions.
|
||||
""" removeAttributes: [String!]
|
||||
"""
|
||||
Inserts or updates the given attributes.
|
||||
For lists, the entire list must be provided.
|
||||
""" insertAttributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
"The fields that can be updated for a user."
|
||||
input UpdateUserInput {
|
||||
id: String!
|
||||
email: String
|
||||
displayName: String
|
||||
"""
|
||||
First name of user. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" firstName: String
|
||||
"""
|
||||
Last name of user. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" lastName: String
|
||||
"""
|
||||
Base64 encoded JpegPhoto. Deprecated: use attribute instead.
|
||||
If both field and corresponding attribute is supplied, the attribute will take precedence.
|
||||
""" avatar: String
|
||||
"""
|
||||
Attribute names to remove.
|
||||
They are processed before insertions.
|
||||
""" removeAttributes: [String!]
|
||||
"""
|
||||
Inserts or updates the given attributes.
|
||||
For lists, the entire list must be provided.
|
||||
""" insertAttributes: [AttributeValueInput!]
|
||||
}
|
||||
|
||||
"""
|
||||
Combined date and time (with time zone) in [RFC 3339][0] format.
|
||||
|
||||
Represents a description of an exact instant on the time-line (such as the
|
||||
instant that a user account was created).
|
||||
|
||||
[`DateTime` scalar][1] compliant.
|
||||
|
||||
See also [`chrono::DateTime`][2] for details.
|
||||
|
||||
[0]: https://datatracker.ietf.org/doc/html/rfc3339#section-5
|
||||
[1]: https://graphql-scalars.dev/docs/scalars/date-time
|
||||
[2]: https://docs.rs/chrono/latest/chrono/struct.DateTime.html
|
||||
"""
|
||||
scalar DateTime
|
||||
|
||||
type AttributeList {
|
||||
attributes: [AttributeSchema!]!
|
||||
extraLdapObjectClasses: [String!]!
|
||||
ldapObjectClasses: [ObjectClassInfo!]!
|
||||
}
|
||||
|
||||
type AttributeSchema {
|
||||
name: String!
|
||||
attributeType: AttributeType!
|
||||
isList: Boolean!
|
||||
isVisible: Boolean!
|
||||
isEditable: Boolean!
|
||||
isHardcoded: Boolean!
|
||||
isReadonly: Boolean!
|
||||
}
|
||||
|
||||
type AttributeValue {
|
||||
name: String!
|
||||
value: [String!]!
|
||||
schema: AttributeSchema!
|
||||
}
|
||||
|
||||
type Group {
|
||||
id: Int!
|
||||
displayName: String!
|
||||
creationDate: DateTime!
|
||||
uuid: String!
|
||||
"User-defined attributes."
|
||||
attributes: [AttributeValue!]!
|
||||
"The groups to which this user belongs."
|
||||
users: [User!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(user: CreateUserInput!): User!
|
||||
createGroup(name: String!): Group!
|
||||
createGroupWithDetails(request: CreateGroupInput!): Group!
|
||||
updateUser(user: UpdateUserInput!): Success!
|
||||
updateGroup(group: UpdateGroupInput!): Success!
|
||||
addUserToGroup(userId: String!, groupId: Int!): Success!
|
||||
removeUserFromGroup(userId: String!, groupId: Int!): Success!
|
||||
deleteUser(userId: String!): Success!
|
||||
deleteGroup(groupId: Int!): Success!
|
||||
addUserAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
||||
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
||||
deleteUserAttribute(name: String!): Success!
|
||||
deleteGroupAttribute(name: String!): Success!
|
||||
addUserObjectClass(name: String!): Success!
|
||||
addGroupObjectClass(name: String!): Success!
|
||||
deleteUserObjectClass(name: String!): Success!
|
||||
deleteGroupObjectClass(name: String!): Success!
|
||||
}
|
||||
|
||||
type ObjectClassInfo {
|
||||
objectClass: String!
|
||||
isHardcoded: Boolean!
|
||||
}
|
||||
|
||||
type Query {
|
||||
apiVersion: String!
|
||||
user(userId: String!): User!
|
||||
users(filters: RequestFilter): [User!]!
|
||||
groups: [Group!]!
|
||||
group(groupId: Int!): Group!
|
||||
schema: Schema!
|
||||
}
|
||||
|
||||
type Schema {
|
||||
userSchema: AttributeList!
|
||||
groupSchema: AttributeList!
|
||||
}
|
||||
|
||||
type Success {
|
||||
ok: Boolean!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: String!
|
||||
email: String!
|
||||
displayName: String!
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
avatar: String
|
||||
creationDate: DateTime!
|
||||
uuid: String!
|
||||
"User-defined attributes."
|
||||
attributes: [AttributeValue!]!
|
||||
"The groups to which this user belongs."
|
||||
groups: [Group!]!
|
||||
}
|
||||
Reference in New Issue
Block a user