openapi: 3.0.3

info:
  title: iams-v2 API
  version: "1.0.0"
  description: |
    REST API сервиса **iams** (`platform/iams-v2`) — управление пользователями,
    ресурсами/проектами, оргструктурой (должности, подразделения, группы) и
    правами доступа (IAM).

    Сервис написан на Go (**Fiber**). Приложение собирается в
    `internal/controller/http/server.go` (`NewServer`). Роутинг состоит из двух
    контуров:

    - **внутренний** — группы `/api/v0`, `/api/admin/v0`, `/api/v1`, `/api/v2`;
      аутентификации на уровне приложения нет, доступ ограничивается сетевым
      слоем (через ingress не публикуется);
    - **внешний** — группы `/external/api/v0`, `/external/api/admin/v0`,
      `/external/api/v1`, `/external/api/v2`; регистрируется только при
      `AUTH_ENABLED=true`, публикует подмножество внутренних маршрутов (часть —
      только на чтение).

    Health-check доступен по `GET /api/health`.

    ### Аутентификация
    Внешние эндпоинты требуют заголовок `Authorization: Bearer <jwt>`
    (`internal/controller/http/jwt/parser.go`). **Подпись токена не проверяется** —
    доверие делегируется вышестоящему gateway/virtual service. Схема разбора
    выбирается по заголовку `Identity`:

    1. заголовок `Identity` отсутствует/пуст → legacy-формат (Django SimpleJWT);
    2. заголовок `Identity` задан → Zitadel (клейм
       `urn:zitadel:iam:user:metadata`, значения полей — base64).

    Внутренние эндпоинты (`/api/*`) аутентификации на уровне приложения не требуют.

    ### Пагинация
    Списочные ответы используют `limit`/`offset` (по умолчанию `limit=1000`) и
    оборачиваются в `{ count, results }`.

    ### Обработка ошибок
    Ошибки возвращаются как JSON `{ error, id }` (`httpx.Response`). Доменный
    `Kind` маппится на HTTP-статус (`httpx/error_resolve.go`):
    `InvalidArgument`/`OutOfRange` → `400`, `Unauthenticated` → `401`,
    `PermissionDenied` → `403`, `NotFound` → `404`,
    `Aborted`/`AlreadyExists` → `409`, `PreconditionFailed` → `412`,
    `ResourceExhausted` → `429`, `Unavailable` → `503`,
    `Internal`/`Unknown`/`DataLoss` → `500`.

servers:
  - url: /api
    description: Внутренний контур (без auth)
  - url: /external/api
    description: Внешний контур (JWT, только при AUTH_ENABLED=true)

tags:
  - name: health
  - name: users
  - name: permission-check
  - name: groups
  - name: positions
  - name: departments
  - name: django-permissions
  - name: resource-types
  - name: resources-v0
  - name: projects-v0
  - name: widgets-v0
  - name: resources
  - name: widgets
  - name: resource-permissions
  - name: company-resource-permissions
  - name: service-accounts

paths:
  /health:
    get:
      tags: [health]
      summary: Health-check
      description: Проверка БД и (при включении) Kafka. Обслуживается как `/api/health`.
      security: []
      responses:
        "200":
          description: Сервис доступен
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
        "503":
          description: Недоступна зависимость (БД/Kafka)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: unhealthy }
                  error: { type: string, example: database_unavailable }

  /v0/users:
    get:
      tags: [users]
      summary: Список пользователей
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - { name: username, in: query, schema: { type: string } }
        - { name: search, in: query, schema: { type: string } }
        - { name: is_active, in: query, schema: { type: boolean } }
        - { name: is_staff, in: query, schema: { type: boolean } }
        - { name: is_superuser, in: query, schema: { type: boolean } }
        - { name: service_account_id, in: query, schema: { type: string, format: uuid } }
        - { name: id, in: query, description: "CSV из id", schema: { type: string } }
        - { name: company_id, in: query, schema: { type: integer } }
        - { name: departments, in: query, description: "CSV", schema: { type: string } }
        - { name: positions, in: query, description: "CSV", schema: { type: string } }
        - { name: groups, in: query, description: "CSV", schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [users]
      summary: Создать пользователя
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserCreateInput" }
      responses:
        "201":
          description: Создан
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      tags: [users]
      summary: Массовое обновление пользователей (legacy bulk_update)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: { $ref: "#/components/schemas/UserBulkPatchItem" }
      responses:
        "200":
          description: OK
        default: { $ref: "#/components/responses/Error" }

  /v0/users/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
    get:
      tags: [users]
      summary: Пользователь по id
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        default: { $ref: "#/components/responses/Error" }
    put:
      tags: [users]
      summary: Полное обновление пользователя
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserUpdateInput" }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    patch:
      tags: [users]
      summary: Частичное обновление пользователя
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [users]
      summary: Удалить пользователя
      responses:
        "204": { description: Удалён }
        default: { $ref: "#/components/responses/Error" }

  /admin/v0/users/activation:
    post:
      tags: [users]
      summary: Активация/деактивация пользователей компании
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: { $ref: "#/components/schemas/CompanyUserActivationItem" }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }

  /v1/users-with-resources:
    post:
      tags: [users]
      summary: Пользователи с их ресурсами
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, id]
              properties:
                tenant_id: { type: integer, format: int64 }
                id:
                  type: array
                  items: { type: integer, format: int64 }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }

  /v1/users-grouped-by-resource/:
    get:
      tags: [users]
      summary: Пользователи, сгруппированные по ресурсу
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }

  /v0/permissions-check:
    post:
      tags: [permission-check]
      summary: Проверка прав пользователя
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserPermissionCheckInput" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserPermissionCheckOutput" }
        default: { $ref: "#/components/responses/Error" }

  /v0/groups:
    get:
      tags: [groups]
      summary: Список групп
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [groups]
      summary: Создать группу
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GroupCreateInput" }
      responses:
        "201": { description: Создана }
        default: { $ref: "#/components/responses/Error" }

  /v0/groups/search:
    post:
      tags: [groups]
      summary: Поиск групп
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }

  /v0/groups/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [groups]
      summary: Группа по id
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    put:
      tags: [groups]
      summary: Обновить группу
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/GroupUpdateInput" } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    patch:
      tags: [groups]
      summary: Частично обновить группу
      requestBody:
        required: true
        content: { application/json: { schema: { type: object } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [groups]
      summary: Удалить группу
      responses:
        "204": { description: Удалена }
        default: { $ref: "#/components/responses/Error" }

  /v0/positions:
    get:
      tags: [positions]
      summary: Список должностей
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [positions]
      summary: Создать должность
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PositionCreateInput" } } }
      responses:
        "201": { description: Создана }
        default: { $ref: "#/components/responses/Error" }

  /v0/positions/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [positions]
      summary: Должность по id
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    put:
      tags: [positions]
      summary: Обновить должность
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PositionUpdateInput" } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    patch:
      tags: [positions]
      summary: Частично обновить должность
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [positions]
      summary: Удалить должность
      responses:
        "204": { description: Удалена }
        default: { $ref: "#/components/responses/Error" }

  /v0/departments:
    get:
      tags: [departments]
      summary: Список подразделений
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [departments]
      summary: Создать подразделение
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/DepartmentCreateInput" } } }
      responses:
        "201": { description: Создано }
        default: { $ref: "#/components/responses/Error" }

  /v0/departments/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [departments]
      summary: Подразделение по id
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    put:
      tags: [departments]
      summary: Обновить подразделение
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/DepartmentUpdateInput" } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    patch:
      tags: [departments]
      summary: Частично обновить подразделение
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [departments]
      summary: Удалить подразделение
      responses:
        "204": { description: Удалено }
        default: { $ref: "#/components/responses/Error" }

  /v0/permissions:
    get:
      tags: [django-permissions]
      summary: Список permission'ов
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [django-permissions]
      summary: Создать permission
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, codename]
              properties:
                name: { type: string }
                codename: { type: string }
      responses:
        "201": { description: Создан }
        default: { $ref: "#/components/responses/Error" }

  /v0/permissions/search:
    post:
      tags: [django-permissions]
      summary: Поиск permission'ов
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }

  /v0/permissions/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [django-permissions]
      summary: Permission по id
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [django-permissions]
      summary: Удалить permission
      responses:
        "204": { description: Удалён }
        default: { $ref: "#/components/responses/Error" }

  /v0/resource-types:
    get:
      tags: [resource-types]
      summary: Список типов ресурсов
      responses:
        "200": { description: OK }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [resource-types]
      summary: Создать тип
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parent_id: { type: integer, format: int64, nullable: true }
      responses:
        "201": { description: Создан }
        default: { $ref: "#/components/responses/Error" }

  /v0/resource-types/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [resource-types]
      summary: Тип по id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    put:
      tags: [resource-types]
      summary: Обновить тип
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [resource-types]
      summary: Частично обновить тип
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [resource-types]
      summary: Удалить тип
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v0/resources:
    get:
      tags: [resources-v0]
      summary: Список ресурсов (v0)
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/ResourceV0Output" }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [resources-v0]
      summary: Создать ресурс
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, tenant_id]
              properties:
                name: { type: string }
                parent_id: { type: string, nullable: true }
                type_id: { type: integer, format: int64 }
                tenant_id: { type: integer, format: int64, minimum: 1 }
                created_by: { type: integer, format: int64 }
      responses: { "201": { description: Создан }, default: { $ref: "#/components/responses/Error" } }

  /v0/resources/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
    get:
      tags: [resources-v0]
      summary: Ресурс по id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    put:
      tags: [resources-v0]
      summary: Обновить ресурс
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [resources-v0]
      summary: Частично обновить ресурс
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [resources-v0]
      summary: Удалить ресурс
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v0/projects:
    get:
      tags: [projects-v0]
      summary: Список проектов
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    post:
      tags: [projects-v0]
      summary: Создать проект
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "201": { description: Создан }, default: { $ref: "#/components/responses/Error" } }

  /v0/projects/{publicID}:
    parameters: [ { name: publicID, in: path, required: true, schema: { type: string, format: uuid } } ]
    get:
      tags: [projects-v0]
      summary: Проект по public id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    put:
      tags: [projects-v0]
      summary: Обновить проект
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [projects-v0]
      summary: Частично обновить проект
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [projects-v0]
      summary: Удалить проект
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v0/widgets:
    get:
      tags: [widgets-v0]
      summary: Список виджетов
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    post:
      tags: [widgets-v0]
      summary: Создать виджет
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "201": { description: Создан }, default: { $ref: "#/components/responses/Error" } }

  /v0/widgets/{publicID}:
    parameters: [ { name: publicID, in: path, required: true, schema: { type: string, format: uuid } } ]
    get:
      tags: [widgets-v0]
      summary: Виджет по public id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    put:
      tags: [widgets-v0]
      summary: Обновить виджет
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [widgets-v0]
      summary: Частично обновить виджет
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [widgets-v0]
      summary: Удалить виджет
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v1/resource:
    get:
      tags: [resources]
      summary: Список ресурсов (v1)
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - { name: parent_id, in: query, schema: { type: string, format: uuid } }
        - { name: type, in: query, schema: { type: integer } }
        - { name: tenant_id, in: query, description: "CSV", schema: { type: string } }
        - { name: name, in: query, schema: { type: string } }
        - { name: code, in: query, schema: { type: string } }
        - { name: public_id, in: query, schema: { type: string, format: uuid } }
        - { name: target_id, in: query, schema: { type: integer } }
        - { name: service_accounts, in: query, description: "CSV из uuid", schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Resource" }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [resources]
      summary: Создать ресурс
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResourceCreateInput" }
      responses: { "201": { description: Создан }, default: { $ref: "#/components/responses/Error" } }

  /v1/resource/{publicID}:
    parameters: [ { name: publicID, in: path, required: true, schema: { type: string, format: uuid } } ]
    get:
      tags: [resources]
      summary: Ресурс по public id
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/Resource" } } }
        default: { $ref: "#/components/responses/Error" }
    put:
      tags: [resources]
      summary: Обновить ресурс
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ResourceUpdateInput" } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [resources]
      summary: Частично обновить ресурс
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [resources]
      summary: Удалить ресурс
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v1/targets/{target_id}/resource:
    parameters: [ { name: target_id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [resources]
      summary: Ресурс по target id (только внутренний контур)
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/Resource" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/resources-grouped-by-sa:
    get:
      tags: [resources]
      summary: Ресурсы, сгруппированные по сервисному аккаунту
      description: Регистрируется, только если включён соответствующий use case.
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }

  /v1/service-accounts:
    get:
      tags: [service-accounts]
      summary: Список сервисных аккаунтов
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  results:
                    type: array
                    items: { type: string, format: uuid }
        default: { $ref: "#/components/responses/Error" }

  /v2/resource:
    get:
      tags: [widgets]
      summary: Список виджетов (v2)
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    post:
      tags: [widgets]
      summary: Создать виджет
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/WidgetCreateInput" } } } }
      responses: { "201": { description: Создан }, default: { $ref: "#/components/responses/Error" } }

  /v2/resource/{publicID}:
    parameters: [ { name: publicID, in: path, required: true, schema: { type: string, format: uuid } } ]
    get:
      tags: [widgets]
      summary: Виджет по public id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    put:
      tags: [widgets]
      summary: Обновить виджет
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    patch:
      tags: [widgets]
      summary: Частично обновить виджет
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [widgets]
      summary: Удалить виджет
      responses: { "204": { description: Удалён }, default: { $ref: "#/components/responses/Error" } }

  /v1/resource_permission:
    get:
      tags: [resource-permissions]
      summary: Список прав на ресурсы
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    post:
      tags: [resource-permissions]
      summary: Создать права (пакет)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items: { $ref: "#/components/schemas/ResourcePermissionCreateInput" }
      responses: { "201": { description: Создано }, default: { $ref: "#/components/responses/Error" } }

  /v1/resource_permission/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [resource-permissions]
      summary: Право по id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [resource-permissions]
      summary: Удалить право
      responses: { "204": { description: Удалено }, default: { $ref: "#/components/responses/Error" } }

  /v1/permissions-bulk:
    patch:
      tags: [resource-permissions]
      summary: Массовое изменение прав
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, permissions]
              properties:
                tenant_id: { type: integer, format: int64, minimum: 1 }
                permissions:
                  type: object
                  description: "map[resource_uuid][]permission_uuid"
                  additionalProperties:
                    type: array
                    items: { type: string, format: uuid }
                unrestricted_permissions:
                  type: object
                  additionalProperties: true
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }

  /v1/company_resource_permission:
    get:
      tags: [company-resource-permissions]
      summary: Список прав компании
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    post:
      tags: [company-resource-permissions]
      summary: Создать права компании (пакет)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items: { $ref: "#/components/schemas/CompanyResourcePermissionCreateInput" }
      responses: { "201": { description: Создано }, default: { $ref: "#/components/responses/Error" } }

  /v1/company_resource_permission/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ]
    get:
      tags: [company-resource-permissions]
      summary: Право компании по id
      responses: { "200": { description: OK }, default: { $ref: "#/components/responses/Error" } }
    delete:
      tags: [company-resource-permissions]
      summary: Удалить право компании
      responses: { "204": { description: Удалено }, default: { $ref: "#/components/responses/Error" } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        `Authorization: Bearer <jwt>`. Требуется во внешнем контуре
        (`/external/api/*`). Дополнительный заголовок `Identity` переключает
        разбор в режим Zitadel. Подпись не проверяется приложением.

  parameters:
    Limit:
      name: limit
      in: query
      description: Размер страницы (по умолчанию 1000)
      schema: { type: integer, default: 1000, minimum: 1 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, default: 0, minimum: 0 }

  responses:
    Error:
      description: Ошибка
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

  schemas:
    ApiError:
      type: object
      properties:
        error: { type: string, description: Человекочитаемое сообщение }
        id: { type: string, description: Стабильный slug доменной ошибки }

    User:
      type: object
      properties:
        id: { type: integer, format: int64 }
        username: { type: string }
        email: { type: string, format: email }
        first_name: { type: string }
        last_name: { type: string }
        is_staff: { type: boolean }
        is_active: { type: boolean }
        is_superuser: { type: boolean }
        job_title: { type: string }
        companies:
          type: array
          items: { type: integer, format: int64 }

    UserCreateInput:
      type: object
      required: [username, email, first_name, last_name]
      properties:
        username: { type: string }
        email: { type: string, format: email }
        first_name: { type: string }
        last_name: { type: string }
        is_staff: { type: boolean }
        is_active: { type: boolean }
        is_superuser: { type: boolean }
        job_title: { type: string }
        companies:
          type: array
          items: { type: integer, format: int64 }
        departments:
          type: array
          items: { type: integer, format: int64 }
        groups:
          type: array
          items: { type: integer, format: int64 }
        enable_notifications: { type: boolean }

    UserUpdateInput:
      type: object
      required: [username, email]
      properties:
        username: { type: string }
        email: { type: string, format: email }
        first_name: { type: string }
        last_name: { type: string }
        is_staff: { type: boolean }
        is_active: { type: boolean }
        is_superuser: { type: boolean }
        job_title: { type: string }
        companies:
          type: array
          items: { type: integer, format: int64 }

    UserBulkPatchItem:
      type: object
      required: [id]
      properties:
        id: { type: integer, format: int64 }
        username: { type: string }
        email: { type: string, format: email }
        first_name: { type: string }
        last_name: { type: string }
        is_staff: { type: boolean }
        is_active: { type: boolean }
        is_superuser: { type: boolean }
        job_title: { type: string }
        companies: { type: array, items: { type: integer, format: int64 } }
        departments: { type: array, items: { type: integer, format: int64 } }
        groups: { type: array, items: { type: integer, format: int64 } }
        enable_notifications: { type: boolean }

    CompanyUserActivationItem:
      type: object
      required: [user_id, company_id]
      properties:
        user_id: { type: integer, format: int64, minimum: 1 }
        company_id: { type: integer, format: int64, minimum: 1 }
        is_active: { type: boolean }

    UserPermissionCheckInput:
      type: object
      required: [user_id, checks]
      properties:
        user_id: { type: integer, format: int64, minimum: 1 }
        tenant_id: { type: integer, format: int64 }
        checks:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [permission]
            properties:
              permission: { type: string }
              public_resource_id: { type: string, format: uuid, nullable: true }

    UserPermissionCheckOutput:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              permission: { type: string }
              public_resource_id: { type: string, format: uuid, nullable: true }
              allowed: { type: boolean }
              reason: { type: string }

    GroupCreateInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        description: { type: string }
        is_public: { type: boolean }
        company_id: { type: integer, format: int64, nullable: true }
        permission_ids: { type: array, items: { type: integer, format: int64 } }

    GroupUpdateInput:
      allOf:
        - $ref: "#/components/schemas/GroupCreateInput"
        - type: object
          required: [id]
          properties:
            id: { type: integer, format: int64 }

    PositionCreateInput:
      type: object
      required: [name, company_id]
      properties:
        name: { type: string }
        description: { type: string }
        company_id: { type: integer, format: int64, minimum: 1 }
        users: { type: array, items: { type: integer, format: int64 } }
        groups: { type: array, items: { type: integer, format: int64 } }

    PositionUpdateInput:
      allOf:
        - $ref: "#/components/schemas/PositionCreateInput"
        - type: object
          required: [id]
          properties:
            id: { type: integer, format: int64 }

    DepartmentCreateInput:
      type: object
      required: [name, company_id]
      properties:
        name: { type: string }
        description: { type: string }
        company_id: { type: integer, format: int64, minimum: 1 }
        legal_entity: { type: string }
        contractor: { type: object, nullable: true }
        users: { type: array, items: { type: integer, format: int64 } }
        groups: { type: array, items: { type: integer, format: int64 } }

    DepartmentUpdateInput:
      allOf:
        - $ref: "#/components/schemas/DepartmentCreateInput"
        - type: object
          required: [id]
          properties:
            id: { type: integer, format: int64 }

    Resource:
      type: object
      properties:
        public_id: { type: string, format: uuid }
        parent_id: { type: string, format: uuid, nullable: true }
        type_id: { type: integer, format: int64 }
        tenant_id: { type: integer, format: int64 }
        name: { type: string }
        description: { type: string }
        code: { type: string }
        target_id: { type: integer, format: int64 }
        show_in_overview: { type: boolean }
        location_verbose: { type: string }
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }
        widgets: { type: object, additionalProperties: true }
        attributes: { type: object, additionalProperties: true }
        meta: { type: object, additionalProperties: true }

    ResourceCreateInput:
      type: object
      required: [name, tenant_id]
      properties:
        parent_id: { type: string, format: uuid, nullable: true, description: "public_id родителя" }
        name: { type: string }
        type_id: { type: integer, format: int64, description: "0/опущено — тип «Проект»" }
        tenant_id: { type: integer, format: int64, minimum: 1 }
        created_by: { type: integer, format: int64 }
        target_id: { type: integer, format: int64 }
        code: { type: string }

    ResourceUpdateInput:
      type: object
      required: [name, tenant_id]
      properties:
        parent_id: { type: string, format: uuid, nullable: true }
        type_id: { type: integer, format: int64 }
        tenant_id: { type: integer, format: int64, minimum: 1 }
        name: { type: string }
        description: { type: string }
        code: { type: string }
        target_id: { type: integer, format: int64 }
        planning_widget_id: { type: integer, format: int64, nullable: true }
        work_schedule_project_id: { type: integer, format: int64, nullable: true }
        show_in_overview: { type: boolean }
        location_verbose: { type: string }
        latitude: { type: number, format: double, minimum: -90, maximum: 90 }
        longitude: { type: number, format: double, minimum: -180, maximum: 180 }
        widgets: { type: object, additionalProperties: true }
        attributes: { type: object, additionalProperties: true }
        meta: { type: object, additionalProperties: true }

    ResourceV0Output:
      type: object
      properties:
        id: { type: string }
        public_id: { type: string }
        parent_id: { type: string, nullable: true }
        path: { type: string }
        type_id: { type: integer, format: int64 }
        tenant_id: { type: integer, format: int64 }
        name: { type: string }
        created_by: { type: integer, format: int64 }
        created_at: { type: string }
        updated_at: { type: string }

    WidgetCreateInput:
      type: object
      required: [name, tenant_id]
      properties:
        parent_id: { type: string, format: uuid, nullable: true }
        type_id: { type: integer, format: int64 }
        tenant_id: { type: integer, format: int64, minimum: 1 }
        name: { type: string }
        description: { type: string }
        code: { type: string }
        target_id: { type: integer, format: int64 }
        show_in_overview: { type: boolean }
        latitude: { type: number, format: double, minimum: -90, maximum: 90 }
        longitude: { type: number, format: double, minimum: -180, maximum: 180 }
        coordinate_system: { type: integer, format: int64 }
        widgets: { type: object, additionalProperties: true }
        attributes: { type: object, additionalProperties: true }
        meta: { type: object, additionalProperties: true }

    ResourcePermissionCreateInput:
      type: object
      required: [public_resource_id, service_account]
      properties:
        public_resource_id: { type: string, format: uuid }
        type: { type: integer, format: int64, minimum: 0 }
        service_account: { type: string, format: uuid }
        created_by: { type: integer, format: int64 }

    CompanyResourcePermissionCreateInput:
      type: object
      required: [tenant_id, service_account]
      properties:
        tenant_id: { type: integer, format: int64, minimum: 1 }
        service_account: { type: string, format: uuid }
        created_by: { type: integer, format: int64 }

security:
  - bearerAuth: []
