From f1c4fdbe3e626cd64c1d41f469bb32df94f64be9 Mon Sep 17 00:00:00 2001 From: Unknowns24 Date: Sun, 18 Oct 2026 21:13:15 -0300 Subject: [PATCH 1/2] docs(sep): audit credential authorization and propose TDD plan --- docs/PLAN_SEP_PERMISOS_CREDENCIAL.md | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/PLAN_SEP_PERMISOS_CREDENCIAL.md diff --git a/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md b/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md new file mode 100644 index 0000000..cc8703d --- /dev/null +++ b/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md @@ -0,0 +1,32 @@ +# Cambio SEP propuesto: permisos determinados por Integration Key + +Fecha: 2026-10-18. Estado: auditoría completa; implementación pendiente de aprobación TDD coordinada con frontend. + +Rama: `codex/sep-permisos-por-credencial`, creada desde `origin/main` actualizado (`bc407552513560fa921b4bc637bac416239f2e33`); no existe develop en este repositorio. + +## Hallazgo + +El SDK sólo expone SEP M2M mediante CrearSesion/CrearBootstrap. Ambas funciones reciben SolicitudCrearSesionEmbedded, validan Permisos y serializan `{"data":{"permissions":[...]}}`. No implementa administración SEP ni canje de navegador. El catálogo tiene doce permisos, incluido invoices:create; no existe invoices:write. + +## API propuesta + +```go +sesion, err := clienteSEP.CrearSesion(ctx) +bootstrap, err := clienteSEP.CrearBootstrap(ctx) +``` + +Eliminar DTO público vacío y validación de permisos del consumidor. El transporte enviará POST sin cuerpo; la clave configurada determina integración, emisor, credencial y todos sus permisos. Mantener tipos legítimos del catálogo y toda la seguridad de capacidades, redacción, no redirects, no cookies y no retry de POST. No agregar administración al SDK. + +Actualizar modelos/cliente/transporte/tests/ejemplos/README, consumidor externo anidado y procedencia del OpenAPI. Contrato propuesto 4.0.0, mismo /api/v2, YAML idéntico byte a byte al backend/frontend. El YAML actual sigue en 3.0.0, SHA-256 b9580c0245564ce8cf3d11930b36650b8b6e8934b3910c492b4c9a5ae0557238. + +La auditoría completa y el plan TDD están en backend/docs/AUDITORIA_SEP_PERMISOS_CREDENCIAL.md y backend/docs/PLAN_TDD_SEP_PERMISOS_CREDENCIAL.md del repositorio canónico gofip-backend. El frontend guarda también el plan en .harness/tasks/sep-permisos-por-credencial/tdd-test-plan.md. Se requiere aprobación del plan antes de implementación. + +## Versionado y Elixir + +Propuesta de siguiente prerelease: v2.0.0-rc.4. Es incompatible a nivel de API Go con v2.0.0-rc.3, dentro de una serie prerelease; no crear tag ni release. Un SDK major estable requeriría respetar SemVer y evaluar v3 si correspondiera. + +Elixir deberá actualizar posteriormente el SDK y quitar SolicitudCrearSesionEmbedded{Permisos: ...} en ambas llamadas. GOFIP_SEP_INTEGRATION_KEY basta; no agregar env vars para permisos. No se modifica Elixir en esta tarea. + +## Verificación previa + +`go test ./...` pasó sobre la base oficial. No incluye el módulo anidado integration/consumer. Race/vet, módulo externo, pruebas del contrato nuevo y validación completa se ejecutarán después de implementar. No se publicó ningún cambio. From 6cdedafe71dfd1bc3ad82152328b910e72c27583 Mon Sep 17 00:00:00 2001 From: Unknowns24 Date: Sun, 18 Oct 2026 21:56:31 -0300 Subject: [PATCH 2/2] feat(sep)!: create embedded sessions without requested permissions --- README.md | 25 +++-- contrato_importacion_test.go | 4 +- docs/CONTRATO.md | 10 +- docs/PLAN_SEP_PERMISOS_CREDENCIAL.md | 6 +- docs/openapi.yaml | 133 +++++++++++++++----------- examples/sep-bootstrap/main.go | 3 +- examples/sep-sesion/main.go | 3 +- integration/consumer/consumer_test.go | 6 +- modelos/sep/sep.go | 25 ----- modelos/sep/sep_test.go | 16 ---- sep.go | 17 ++-- sep_test.go | 60 +++--------- transporte_sep.go | 12 +-- 13 files changed, 125 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index a388522..b46496a 100644 --- a/README.md +++ b/README.md @@ -344,20 +344,15 @@ idempotencia y límites de PDF. No hay batch ni emisión como fallback. ## Integraciones SEP: un cliente y una credencial distintos ```go -import sepmodel "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" - clienteSEP, err := gofip.NuevoClienteSEP(gofip.OpcionesClienteSEP{ ClaveIntegracion: os.Getenv("GOFIP_SEP_KEY"), URLBase: os.Getenv("GOFIP_API_URL"), // obligatoria, sin valor por defecto ClienteHTTP: &http.Client{Timeout: 15 * time.Second}, }) if err != nil { return err } -solicitud := sepmodel.SolicitudCrearSesionEmbedded{ - Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer}, -} -sesion, err := clienteSEP.CrearSesion(ctx, solicitud) +sesion, err := clienteSEP.CrearSesion(ctx) // Ante una recuperación, solicitar un bootstrap nuevo: -bootstrap, err := clienteSEP.CrearBootstrap(ctx, solicitud) +bootstrap, err := clienteSEP.CrearBootstrap(ctx) ``` | Credencial | Cliente | Uso | @@ -366,9 +361,8 @@ bootstrap, err := clienteSEP.CrearBootstrap(ctx, solicitud) | `GOFIP_SEP_KEY` | `NuevoClienteSEP` | IntegrationKey; iniciar y recuperar acceso Embedded | SEP envía exclusivamente `Authorization: IntegrationKey ...`, sin ambiente ARCA. -Crea requests `data` y decodifica responses `data`. Los permisos se validan contra -los doce valores de OpenAPI: no admite listas vacías, valores desconocidos ni -duplicados. Sólo el backend decide si caben dentro del techo autorizado. +Envía POST sin cuerpo y decodifica responses `data`. El backend carga todos los permisos +Embedded de la credencial autenticada; el consumidor no replica ni selecciona permisos. `invoices:create` es un valor del enum y no agrega un endpoint de emisión Embedded. `CrearSesion` devuelve URL de documento, vencimientos y bootstrap. Todavía no es @@ -411,8 +405,13 @@ go test -race ./... ``` Ver [arquitectura](docs/arquitectura.md) y [validación manual](docs/validacion.md). -El contrato de referencia es OpenAPI 3.0.0 del backend. No se publica una versión -como parte de estas pruebas. La secuencia prevista es `v2.0.0-rc.1` → `v2.0.0`, +El contrato de referencia es OpenAPI 4.0.0 del backend. No se publica una versión +como parte de estas pruebas. El siguiente prerelease recomendado es `v2.0.0-rc.4` (breaking frente a rc.3), con imports `/v2`, validación externa y aprobación de publicación. -SEP deriva el emisor exclusivamente de la Integration Key. CrearSesion y CrearBootstrap reciben solo permisos; la integración pertenece a un único emisor. +SEP deriva el emisor exclusivamente de la Integration Key. CrearSesion y CrearBootstrap no reciben cuerpo ni permisos. Ambas heredan todos los permisos Embedded de la credencial autenticada; la integración pertenece a un único emisor. + +Cambio incompatible desde v2.0.0-rc.3: quitar SolicitudCrearSesionEmbedded y su lista Permisos. +La Integration Key configura la autorización en GOFIP; no replicar permisos en código ni variables de entorno. +Sin permisos configurados, ambas operaciones reciben 403. Las capacidades ya emitidas conservan su snapshot y TTL; +los cambios de permisos sólo afectan capacidades nuevas. No hay publicación/tag desde este cambio; siguiente prerelease sugerido v2.0.0-rc.4. diff --git a/contrato_importacion_test.go b/contrato_importacion_test.go index 01f12ab..60cbdb3 100644 --- a/contrato_importacion_test.go +++ b/contrato_importacion_test.go @@ -22,10 +22,10 @@ func TestContratoImportacionYProcedenciaExacta(t *testing.T) { t.Fatal(err) } hash := fmt.Sprintf("%x", sha256.Sum256(yaml)) - if !strings.Contains(string(provenance), hash) || !strings.Contains(string(provenance), "Versión: 3.0.0.") { + if !strings.Contains(string(provenance), hash) || !strings.Contains(string(provenance), "Versión: 4.0.0.") { t.Fatal("copia OpenAPI sin procedencia o checksum actualizado") } - for _, token := range []string{`version: "3.0.0"`, "/api/v2/facturas/importaciones:", "mismo emisor y punto de venta", "emisor_id+credencial_id+punto_venta_id", "legacy_import", "SolicitudImportarFactura:", "RespuestaImportarFactura:", "DecimalHistorico:", "VerificacionImportacion:", "CompletitudDocumental:"} { + for _, token := range []string{`version: "4.0.0"`, "/api/v2/facturas/importaciones:", "mismo emisor y punto de venta", "emisor_id+credencial_id+punto_venta_id", "legacy_import", "SolicitudImportarFactura:", "RespuestaImportarFactura:", "DecimalHistorico:", "VerificacionImportacion:", "CompletitudDocumental:"} { if !strings.Contains(string(yaml), token) { t.Fatalf("contrato sin %s", token) } diff --git a/docs/CONTRATO.md b/docs/CONTRATO.md index 4c07e43..d6f7562 100644 --- a/docs/CONTRATO.md +++ b/docs/CONTRATO.md @@ -2,11 +2,11 @@ - Repositorio canónico: unkcode-org/gofip-backend. - Archivo: backend/docs/openapi.yaml. -- Versión: 3.0.0. -- Commit de implementación SEP en backend: 0fbb4e9f60b61eb2857b259b5766bbcec99d5a8b. -- Rama de revisión: codex/sep-emisor-directo. -- Base auditada de develop: dae24d3. -- SHA-256 de los bytes YAML copiados: b9580c0245564ce8cf3d11930b36650b8b6e8934b3910c492b4c9a5ae0557238. +- Versión: 4.0.0. +- Commit de implementación SEP en backend: 1fe864b7f451a96015d1d9bfa92020aa5c13f69b. +- Rama de revisión: codex/sep-permisos-por-credencial. +- Base auditada de develop: 02d3982. +- SHA-256 de los bytes YAML copiados: 9e22af0a501e7f39648100b9d10dfe043b778562eb45e172fc6f7de847337316. La copia se verificó byte a byte después de estabilizar Backend/OpenAPI. Incluye importación Legacy verificada en Producción y no agrega ambiente a documentos o diff --git a/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md b/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md index cc8703d..3f47cd2 100644 --- a/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md +++ b/docs/PLAN_SEP_PERMISOS_CREDENCIAL.md @@ -1,12 +1,12 @@ # Cambio SEP propuesto: permisos determinados por Integration Key -Fecha: 2026-10-18. Estado: auditoría completa; implementación pendiente de aprobación TDD coordinada con frontend. +Fecha: 2026-10-18. Registro del plan previo, aprobado explícitamente por el usuario. Implementación completada; ver README y procedencia en CONTRATO.md. Rama: `codex/sep-permisos-por-credencial`, creada desde `origin/main` actualizado (`bc407552513560fa921b4bc637bac416239f2e33`); no existe develop en este repositorio. ## Hallazgo -El SDK sólo expone SEP M2M mediante CrearSesion/CrearBootstrap. Ambas funciones reciben SolicitudCrearSesionEmbedded, validan Permisos y serializan `{"data":{"permissions":[...]}}`. No implementa administración SEP ni canje de navegador. El catálogo tiene doce permisos, incluido invoices:create; no existe invoices:write. +El SDK sólo expone SEP M2M mediante CrearSesion/CrearBootstrap. Antes del cambio ambas funciones recibían SolicitudCrearSesionEmbedded, validaban Permisos y serializaban `{"data":{"permissions":[...]}}`. No implementa administración SEP ni canje de navegador. El catálogo tiene doce permisos, incluido invoices:create; no existe invoices:write. ## API propuesta @@ -19,7 +19,7 @@ Eliminar DTO público vacío y validación de permisos del consumidor. El transp Actualizar modelos/cliente/transporte/tests/ejemplos/README, consumidor externo anidado y procedencia del OpenAPI. Contrato propuesto 4.0.0, mismo /api/v2, YAML idéntico byte a byte al backend/frontend. El YAML actual sigue en 3.0.0, SHA-256 b9580c0245564ce8cf3d11930b36650b8b6e8934b3910c492b4c9a5ae0557238. -La auditoría completa y el plan TDD están en backend/docs/AUDITORIA_SEP_PERMISOS_CREDENCIAL.md y backend/docs/PLAN_TDD_SEP_PERMISOS_CREDENCIAL.md del repositorio canónico gofip-backend. El frontend guarda también el plan en .harness/tasks/sep-permisos-por-credencial/tdd-test-plan.md. Se requiere aprobación del plan antes de implementación. +La auditoría completa y el plan TDD están en backend/docs/AUDITORIA_SEP_PERMISOS_CREDENCIAL.md y backend/docs/PLAN_TDD_SEP_PERMISOS_CREDENCIAL.md del repositorio canónico gofip-backend. El frontend guarda también el plan en .harness/tasks/sep-permisos-por-credencial/tdd-test-plan.md. El usuario aprobó el plan antes de implementar. ## Versionado y Elixir diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 814792a..46d78fe 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,10 +1,10 @@ openapi: 3.0.3 info: title: GO ARCA API - version: "3.0.0" + version: "4.0.0" description: >- API REST para gestión de emisores, puntos de venta, credenciales API y - facturación electrónica con ARCA. Todos los cuerpos JSON de entrada usan el + facturación electrónica con ARCA. Los POST M2M de sesión/bootstrap Embedded no llevan cuerpo. Los cuerpos JSON de entrada usan el sobre `{"data": ...}` que declara cada operación. Los errores se devuelven con la estructura `DetallesError`, compatible con RFC 7807; `detail` se localiza en español por defecto y acepta `Accept-Language: es` o `en`. @@ -479,13 +479,13 @@ paths: $ref: "#/components/responses/Error" "500": $ref: "#/components/responses/Error" - /api/v2/emisores/{id-emisor}/integraciones-sep/{integracion-id}/permisos: + /api/v2/emisores/{id-emisor}/integraciones-sep/{integracion-id}/credenciales/{credencial-id}/permisos: put: tags: - Integraciones SEP - operationId: actualizarPermisosSEP - summary: Reemplaza los permisos de la integración - description: Requiere CookieAuth y pertenencia obligatoria del emisor. Una integración o credencial fuera del padre responde 404. Sólo integraciones SEP pertenecientes directamente al emisor; los clientes globales no son accesibles. Los bodies son estrictos; no aceptan emisor, usuario, cliente interno ni scopes. Se aplica a nuevas autenticaciones/autorizaciones. Las sesiones y capacidades ya emitidas conservan su vencimiento actual. + operationId: actualizarPermisosCredencialSEP + summary: Reemplaza los permisos de una credencial SEP + description: Requiere CookieAuth y pertenencia obligatoria del emisor. Una integración o credencial fuera del padre responde 404. Sólo integraciones SEP pertenecientes directamente al emisor; los clientes globales no son accesibles. Los bodies son estrictos; no aceptan emisor, usuario, cliente interno ni scopes. Reemplaza exclusivamente los permisos de esa credencial, sin regenerar secreto ni alterar otras claves. Se aplica a nuevas capacidades. Las sesiones y capacidades ya emitidas conservan su vencimiento actual. security: - CookieAuth: [] parameters: @@ -503,6 +503,11 @@ paths: schema: type: string format: uuid + - name: credencial-id + in: path + required: true + description: Credencial concreta perteneciente a la integración autorizada. + schema: {type: string, format: uuid} requestBody: required: true content: @@ -515,7 +520,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/IntegracionSEP" + $ref: "#/components/schemas/CredencialSEP" "400": $ref: "#/components/responses/Error" "401": @@ -3082,6 +3087,33 @@ paths: "404": {$ref: "#/components/responses/Error"} "500": {$ref: "#/components/responses/Error"} + /api/v2/admin/integration-clients/{integration-client-id}/credentials/{credential-id}/permissions: + put: + tags: [Integraciones Embedded] + operationId: actualizarPermisosCredencialIntegracion + summary: Reemplaza los permisos Embedded de una Integration Key + description: Requiere RBAC admin de gestión de integraciones. Sólo modifica la credencial del padre indicado; no regenera secreto, hash ni scopes. Afecta nuevas capacidades; las existentes conservan snapshot y TTL. Un conjunto vacío impide crear nuevas sesiones/bootstrap. + security: [{CookieAuth: []}] + parameters: + - {$ref: "#/components/parameters/IdClienteIntegracion"} + - {$ref: "#/components/parameters/IdCredencialIntegracion"} + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/CuerpoPermisosSEP"} + responses: + "200": + description: Metadatos seguros de la credencial actualizada. + content: + application/json: + schema: {$ref: "#/components/schemas/CredencialIntegracion"} + "400": {$ref: "#/components/responses/Error"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} + "404": {$ref: "#/components/responses/Error"} + "500": {$ref: "#/components/responses/Error"} + /api/v2/embedded/bootstrap/exchange: post: tags: [Integraciones Embedded] @@ -3123,13 +3155,13 @@ paths: tags: [Integraciones Embedded] operationId: crearBootstrapEmbedded summary: Crea un bootstrap de recuperación Embedded - description: Autentica exclusivamente la IntegrationKey y vuelve a resolver el emisor y el techo de permisos de la integración autenticada. No acepta Document Capability, no crea un documento y no crea una sesión de navegador. + description: >- + Las sesiones Embedded heredan automáticamente todos los permisos configurados en la credencial de integración autenticada. + Autentica exclusivamente IntegrationKey con scope embedded_session:create y deriva el emisor de su integración. + POST sin cuerpo ni parámetros query. No acepta permisos ni selección de emisor del consumidor. + Una credencial sin permisos Embedded recibe 403. La capacidad contiene un snapshot; cambios posteriores + sólo afectan nuevas capacidades. El bootstrap es de un solo uso; no representa aún una sesión de navegador. security: [{IntegrationKeyAuth: []}] - requestBody: - required: true - content: - application/json: - schema: {$ref: "#/components/schemas/CuerpoSolicitudCrearSesionEmbedded"} responses: "201": description: Bootstrap de un solo uso creado. @@ -3150,17 +3182,12 @@ paths: operationId: crearSesionEmbedded summary: Crea un bootstrap Embedded de un solo uso description: >- - Autentica exclusivamente una IntegrationKey M2M con el alcance - `embedded_session:create`. El emisor GOFIP se obtiene exclusivamente de la integración autenticada; nunca enviar `emisor_id` - ni `integration_client_id`. `bootstrap_token` es secreto, se devuelve una sola - vez, vence pronto y se canjea una sola vez mediante - `POST /api/v2/embedded/bootstrap/exchange`; aún no representa una sesión de navegador. + Las sesiones Embedded heredan automáticamente todos los permisos configurados en la credencial de integración autenticada. + Autentica exclusivamente IntegrationKey con scope embedded_session:create y deriva el emisor de su integración. + POST sin cuerpo ni parámetros query. No acepta permisos ni selección de emisor del consumidor. + Una credencial sin permisos Embedded recibe 403. La capacidad contiene un snapshot; cambios posteriores + sólo afectan nuevas capacidades. El bootstrap es de un solo uso; no representa aún una sesión de navegador. security: [{IntegrationKeyAuth: []}] - requestBody: - required: true - content: - application/json: - schema: {$ref: "#/components/schemas/CuerpoSolicitudCrearSesionEmbedded"} responses: "201": description: Bootstrap creado; conservar el token sólo para el canje inmediato y no registrarlo. @@ -5058,7 +5085,6 @@ components: - name - allowed_origin - status - - permissions - created_at - updated_at properties: @@ -5077,12 +5103,6 @@ components: format: uri status: $ref: "#/components/schemas/EstadoClienteIntegracion" - permissions: &a5 - type: array - description: Permisos permitidos al sistema; vacío no autoriza operaciones. - uniqueItems: true - items: - $ref: "#/components/schemas/PermisoEmbedded" created_at: &a1 type: string description: Fecha de creación. @@ -5095,12 +5115,23 @@ components: type: object additionalProperties: false required: + - name + - permissions + - scopes - id - secret_prefix - status - created_at - updated_at properties: + name: {type: string, minLength: 1, maxLength: 120, description: Nombre de esta Integration Key.} + permissions: &a5 + type: array + description: Permisos propios de esta credencial. Las capacidades nuevas heredan todo el conjunto; vacío impide emitir sesión/bootstrap. invoices:create está reservado, sin emisión manual Embedded. + uniqueItems: true + items: + $ref: "#/components/schemas/PermisoEmbedded" + scopes: {type: array, uniqueItems: true, items: {$ref: "#/components/schemas/AlcanceIntegracion"}, description: Scope M2M fijo embedded_session:create en gestión SEP.} id: type: string description: Identificador de credencial. @@ -5178,8 +5209,10 @@ components: data: type: object additionalProperties: false - required: [] + required: [name, permissions] properties: + name: {type: string, minLength: 1, maxLength: 120, description: Nombre de la credencial.} + permissions: *a5 expires_at: type: string description: Vencimiento futuro opcional. @@ -5239,7 +5272,7 @@ components: enum: ["embedded_session:create"] PermisoEmbedded: type: string - description: Capacidad máxima que una futura sesión Embedded podrá solicitar para la integración. + description: Permiso funcional de credencial y snapshot Embedded; separado del scope M2M. invoices:create está reservado y no habilita emisión manual Embedded. WhatsApp y ARCA tienen UI parcial. enum: - "invoices:read" - "invoices:create" @@ -5272,11 +5305,10 @@ components: code: {$ref: "#/components/schemas/CodigoErrorIntegracionEmbedded"} ClienteIntegracion: type: object - required: [id, emisor_id, permissions, name, status, allowed_origin, created_at, updated_at] + required: [id, emisor_id, name, status, allowed_origin, created_at, updated_at] properties: id: {type: string, format: uuid, description: UUIDv7 del cliente de integración.} emisor_id: {type: string, format: uuid, description: Emisor propietario inmutable.} - permissions: {type: array, uniqueItems: true, items: {$ref: "#/components/schemas/PermisoEmbedded"}, description: Techo de permisos de la integración.} name: {type: string, description: Nombre administrativo de la plataforma externa.} status: {$ref: "#/components/schemas/EstadoClienteIntegracion"} allowed_origin: {type: string, format: uri, description: "Origen exacto reservado para la futura política de embedding; nunca incluye ruta, consulta, fragmento ni comodín."} @@ -5292,10 +5324,12 @@ components: items: {$ref: "#/components/schemas/ClienteIntegracion"} CuerpoSolicitudCrearClienteIntegracion: type: object + additionalProperties: false required: [data] properties: data: type: object + additionalProperties: false description: Datos del nuevo cliente de integración, asociado a un único emisor. required: [emisor_id, name, allowed_origin] properties: @@ -5304,21 +5338,24 @@ components: allowed_origin: {type: string, format: uri, description: "Origen HTTP(S) exacto sin path, query, fragmento ni comodín."} CuerpoSolicitudActualizarClienteIntegracion: type: object + additionalProperties: false required: [data] properties: data: type: object + additionalProperties: false minProperties: 1 description: Campos administrativos a modificar. El emisor propietario es inmutable. properties: name: {type: string, minLength: 1, maxLength: 120, description: Nuevo nombre administrativo.} status: {$ref: "#/components/schemas/EstadoClienteIntegracion"} allowed_origin: {type: string, format: uri, description: "Nuevo origen HTTP(S) exacto sin path, query, fragmento ni comodín."} - permissions: {type: array, uniqueItems: true, items: {$ref: "#/components/schemas/PermisoEmbedded"}, description: Nuevo techo de permisos de la integración.} CredencialIntegracion: type: object - required: [id, integration_client_id, secret_prefix, status, scopes, created_at, updated_at] + required: [name, permissions, id, integration_client_id, secret_prefix, status, scopes, created_at, updated_at] properties: + name: {type: string, minLength: 1, maxLength: 120, description: Nombre de la Integration Key.} + permissions: *a5 id: {type: string, format: uuid, description: UUIDv7 de la credencial.} integration_client_id: {type: string, format: uuid, description: Cliente de integración propietario.} secret_prefix: {type: string, example: gofip_int_abcdEFGH1234_567, description: Identificador público seguro para mostrar y buscar la credencial.} @@ -5328,7 +5365,7 @@ components: scopes: type: array uniqueItems: true - description: Alcances M2M de la credencial; son independientes de los permisos Embedded de la integración. + description: Alcances M2M de la credencial; son independientes de los permisos Embedded de la misma credencial. items: {$ref: "#/components/schemas/AlcanceIntegracion"} created_at: {type: string, format: date-time, description: Fecha de creación.} updated_at: {type: string, format: date-time, description: Fecha de última actualización.} @@ -5346,21 +5383,18 @@ components: properties: data: type: object + additionalProperties: false + required: [name, permissions] description: Configuración de la nueva credencial M2M. properties: + name: {type: string, minLength: 1, maxLength: 120, description: Nombre de la Integration Key.} + permissions: *a5 scopes: type: array uniqueItems: true description: Alcances M2M explícitos; omitirlos crea una credencial autenticable sin autorizaciones. items: {$ref: "#/components/schemas/AlcanceIntegracion"} expires_at: {type: string, format: date-time, description: Vencimiento futuro opcional.} - CuerpoSolicitudCrearSesionEmbedded: - type: object - required: [data] - additionalProperties: false - properties: - data: - $ref: "#/components/schemas/SolicitudCrearSesionEmbedded" CuerpoSolicitudCanjearBootstrapEmbedded: type: object required: [data] @@ -5377,17 +5411,6 @@ components: type: string pattern: '^gofip_emb_[A-Za-z0-9_-]{43}$' description: Capacidad opaca, breve y de un único uso. Enviar sólo en este cuerpo POST; no usar query parameters. - SolicitudCrearSesionEmbedded: - type: object - required: [permissions] - additionalProperties: false - properties: - permissions: - type: array - minItems: 1 - uniqueItems: true - description: Permisos exactos solicitados para la futura sesión; deben ser subconjunto del techo de la integración. - items: {$ref: "#/components/schemas/PermisoEmbedded"} RespuestaCrearSesionEmbedded: type: object required: [data] diff --git a/examples/sep-bootstrap/main.go b/examples/sep-bootstrap/main.go index 01a0c22..29e4c1c 100644 --- a/examples/sep-bootstrap/main.go +++ b/examples/sep-bootstrap/main.go @@ -4,7 +4,6 @@ import ( "context" "fmt" gofip "github.com/unkcode-org/gofip-sdk-go/v2" - sepmodel "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" "log" "net/http" "os" @@ -18,7 +17,7 @@ func main() { } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - respuesta, err := c.CrearBootstrap(ctx, sepmodel.SolicitudCrearSesionEmbedded{Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer}}) + respuesta, err := c.CrearBootstrap(ctx) if err != nil { log.Fatal("no se pudo completar la operación SEP") } diff --git a/examples/sep-sesion/main.go b/examples/sep-sesion/main.go index 39c092a..ae21599 100644 --- a/examples/sep-sesion/main.go +++ b/examples/sep-sesion/main.go @@ -4,7 +4,6 @@ import ( "context" "fmt" gofip "github.com/unkcode-org/gofip-sdk-go/v2" - sepmodel "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" "log" "net/http" "os" @@ -18,7 +17,7 @@ func main() { } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - respuesta, err := c.CrearSesion(ctx, sepmodel.SolicitudCrearSesionEmbedded{Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer}}) + respuesta, err := c.CrearSesion(ctx) if err != nil { log.Fatal("no se pudo completar la operación SEP") } diff --git a/integration/consumer/consumer_test.go b/integration/consumer/consumer_test.go index abcde6e..b053d33 100644 --- a/integration/consumer/consumer_test.go +++ b/integration/consumer/consumer_test.go @@ -12,7 +12,6 @@ import ( gofip "github.com/unkcode-org/gofip-sdk-go/v2" "github.com/unkcode-org/gofip-sdk-go/v2/modelos/documentos" "github.com/unkcode-org/gofip-sdk-go/v2/modelos/facturacion" - sepmodel "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" ) // Consume la API pública desde un módulo separado y verifica el contrato HTTP @@ -81,15 +80,14 @@ func TestConsumidorV2(t *testing.T) { if err != nil { t.Fatal(err) } - solicitud := sepmodel.SolicitudCrearSesionEmbedded{Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer}} - sesion, err := sep.CrearSesion(ctx, solicitud) + sesion, err := sep.CrearSesion(ctx) if err != nil { t.Fatal(err) } if sesion.Datos.TokenBootstrap.Valor() == "" { t.Fatal("capacidad ausente") } - if _, err := sep.CrearBootstrap(ctx, solicitud); err != nil { + if _, err := sep.CrearBootstrap(ctx); err != nil { t.Fatal(err) } } diff --git a/modelos/sep/sep.go b/modelos/sep/sep.go index 9d0b9ab..dea6826 100644 --- a/modelos/sep/sep.go +++ b/modelos/sep/sep.go @@ -47,31 +47,6 @@ func (s *Secreto) UnmarshalJSON(b []byte) error { return nil } -type SolicitudCrearSesionEmbedded struct { - Permisos []PermisoEmbedded `json:"permissions"` -} - -// Validar no cambia el orden de los permisos. El techo de -// autorización pertenece al backend y no puede verificarse localmente. -func (s SolicitudCrearSesionEmbedded) Validar() error { - if len(s.Permisos) == 0 { - return errors.New("se requiere al menos un permiso Embedded") - } - vistos := make(map[PermisoEmbedded]bool, len(s.Permisos)) - for _, p := range s.Permisos { - switch p { - case PermisoFacturasLeer, PermisoFacturasCrear, PermisoClientesLeer, PermisoClientesEscribir, PermisoPuntosVentaLeer, PermisoPuntosVentaEscribir, PermisoRecurrentesLeer, PermisoRecurrentesEscribir, PermisoWhatsAppLeer, PermisoWhatsAppEscribir, PermisoARCALeer, PermisoARCAEscribir: - default: - return errors.New("permiso Embedded desconocido") - } - if vistos[p] { - return errors.New("los permisos Embedded no pueden repetirse") - } - vistos[p] = true - } - return nil -} - type RespuestaCrearSesionEmbedded struct { Datos DatosCrearSesionEmbedded `json:"data"` } diff --git a/modelos/sep/sep_test.go b/modelos/sep/sep_test.go index 344805b..298c304 100644 --- a/modelos/sep/sep_test.go +++ b/modelos/sep/sep_test.go @@ -28,19 +28,3 @@ func TestSecretoRedactaFormatosJSONYSlog(t *testing.T) { t.Fatal("secreto no string aceptado") } } - -func TestPermisosExactosYValidacionSinMutaciones(t *testing.T) { - permisos := []PermisoEmbedded{PermisoFacturasLeer, PermisoFacturasCrear, PermisoClientesLeer, PermisoClientesEscribir, PermisoPuntosVentaLeer, PermisoPuntosVentaEscribir, PermisoRecurrentesLeer, PermisoRecurrentesEscribir, PermisoWhatsAppLeer, PermisoWhatsAppEscribir, PermisoARCALeer, PermisoARCAEscribir} - if len(permisos) != 12 { - t.Fatal("inventario incorrecto") - } - for _, p := range permisos { - s := SolicitudCrearSesionEmbedded{Permisos: []PermisoEmbedded{p}} - if err := s.Validar(); err != nil { - t.Fatal(err) - } - if s.Permisos[0] != p { - t.Fatal("validación mutó permisos") - } - } -} diff --git a/sep.go b/sep.go index 81d01c9..2647202 100644 --- a/sep.go +++ b/sep.go @@ -11,14 +11,12 @@ import ( var patronBootstrapSEP = regexp.MustCompile(`^gofip_emb_[A-Za-z0-9_-]{43}$`) -// CrearSesion entrega un documento de framing y un bootstrap de un solo uso. +// CrearSesion hereda todos los permisos de la Integration Key, sin parámetros de autoridad. +// Entrega un documento de framing y un bootstrap de un solo uso. // La sesión del navegador sólo nace durante el canje que realiza el frontend. -func (c *ClienteSEP) CrearSesion(ctx context.Context, s sep.SolicitudCrearSesionEmbedded) (*sep.RespuestaCrearSesionEmbedded, error) { - if err := s.Validar(); err != nil { - return nil, err - } +func (c *ClienteSEP) CrearSesion(ctx context.Context) (*sep.RespuestaCrearSesionEmbedded, error) { var r sep.RespuestaCrearSesionEmbedded - if err := c.post(ctx, "/api/v2/embedded/sessions", s, &r); err != nil { + if err := c.post(ctx, "/api/v2/embedded/sessions", &r); err != nil { return nil, err } u, err := url.Parse(r.Datos.URLDocumento.Valor()) @@ -30,12 +28,9 @@ func (c *ClienteSEP) CrearSesion(ctx context.Context, s sep.SolicitudCrearSesion // CrearBootstrap emite una nueva capacidad de recuperación sin crear documento // ni recuperar secretos anteriores. No reintenta automáticamente el POST. -func (c *ClienteSEP) CrearBootstrap(ctx context.Context, s sep.SolicitudCrearSesionEmbedded) (*sep.RespuestaCrearBootstrapEmbedded, error) { - if err := s.Validar(); err != nil { - return nil, err - } +func (c *ClienteSEP) CrearBootstrap(ctx context.Context) (*sep.RespuestaCrearBootstrapEmbedded, error) { var r sep.RespuestaCrearBootstrapEmbedded - if err := c.post(ctx, "/api/v2/embedded/bootstraps", s, &r); err != nil { + if err := c.post(ctx, "/api/v2/embedded/bootstraps", &r); err != nil { return nil, err } if r.Datos.ExpiraBootstrapEn.IsZero() || !patronBootstrapSEP.MatchString(r.Datos.TokenBootstrap.Valor()) { diff --git a/sep_test.go b/sep_test.go index 685a00e..cc7c65e 100644 --- a/sep_test.go +++ b/sep_test.go @@ -14,18 +14,12 @@ import ( "sync/atomic" "testing" "time" - - sepmodel "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" ) const claveSEPPrueba = "gofip_int_public_secreto-privado" const tokenSEPPrueba = "gofip_emb_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const urlDocumentoSEPPrueba = "https://embedded.example/embed/capacidad-privada" -func solicitudSEPPrueba() sepmodel.SolicitudCrearSesionEmbedded { - return sepmodel.SolicitudCrearSesionEmbedded{Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer}} -} - func TestNuevoClienteSEPConfiguracion(t *testing.T) { for _, o := range []OpcionesClienteSEP{ {}, {ClaveIntegracion: claveSEPPrueba}, {ClaveIntegracion: claveSEPPrueba, URLBase: "/relativa"}, @@ -69,17 +63,12 @@ func TestSEPCreacionYRecuperacionContrato(t *testing.T) { if r.Header.Get("Cookie") != "" || r.Header.Get("X-ARCA-Ambiente") != "" { t.Error("SEP mezcló autenticaciones o ambiente") } - if r.Header.Get("Accept") != "application/json" || r.Header.Get("Content-Type") != "application/json" { + if r.Header.Get("Accept") != "application/json" || r.Header.Get("Content-Type") != "" { t.Error("cabeceras JSON") } - var b struct { - Data map[string]json.RawMessage `json:"data"` - } - if err := json.NewDecoder(r.Body).Decode(&b); err != nil { - t.Fatal(err) - } - if len(b.Data) != 1 || string(b.Data["permissions"]) != `["invoices:read"]` { - t.Error("payload incorrecto") + b, err := io.ReadAll(r.Body) + if err != nil || len(b) != 0 || r.URL.RawQuery != "" { + t.Error("emisión SEP debe tener cuerpo y query vacíos") } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -105,14 +94,14 @@ func TestSEPCreacionYRecuperacionContrato(t *testing.T) { if err != nil { t.Fatal(err) } - sesion, err := c.CrearSesion(context.Background(), solicitudSEPPrueba()) + sesion, err := c.CrearSesion(context.Background()) if err != nil { t.Fatal(err) } if sesion.Datos.TokenBootstrap.Valor() != tokenSEPPrueba || sesion.Datos.URLDocumento.Valor() != urlDocumentoSEPPrueba || sesion.Datos.ExpiraDocumentoEn.Year() != 2026 { t.Fatal("respuesta incorrecta") } - bootstrap, err := c.CrearBootstrap(context.Background(), solicitudSEPPrueba()) + bootstrap, err := c.CrearBootstrap(context.Background()) if err != nil { t.Fatal(err) } @@ -139,29 +128,6 @@ func TestSEPCreacionYRecuperacionContrato(t *testing.T) { } } -func TestSEPValidaPermisosAntesDeEnviar(t *testing.T) { - var llamadas atomic.Int32 - httpClient := &http.Client{Transport: roundTripperPrueba(func(r *http.Request) (*http.Response, error) { - llamadas.Add(1) - return nil, errors.New("no debe llamarse") - })} - c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: "https://example.test", ClienteHTTP: httpClient}) - for _, s := range []sepmodel.SolicitudCrearSesionEmbedded{ - {}, {Permisos: []sepmodel.PermisoEmbedded{"inventado"}}, - {Permisos: []sepmodel.PermisoEmbedded{sepmodel.PermisoFacturasLeer, sepmodel.PermisoFacturasLeer}}, - } { - if _, err := c.CrearSesion(context.Background(), s); err == nil { - t.Fatal("solicitud inválida aceptada") - } - if _, err := c.CrearBootstrap(context.Background(), s); err == nil { - t.Fatal("solicitud inválida aceptada") - } - } - if llamadas.Load() != 0 { - t.Fatal("se envió solicitud inválida") - } -} - func TestSEPErroresRedactadosSinReintentos(t *testing.T) { for _, status := range []int{400, 401, 403, 404, 500, 302} { t.Run(fmt.Sprint(status), func(t *testing.T) { @@ -174,7 +140,7 @@ func TestSEPErroresRedactadosSinReintentos(t *testing.T) { })) defer servidor.Close() c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: servidor.URL, ClienteHTTP: servidor.Client()}) - _, err := c.CrearSesion(context.Background(), solicitudSEPPrueba()) + _, err := c.CrearSesion(context.Background()) if err == nil { t.Fatal("falta error") } @@ -201,12 +167,12 @@ func TestSEPCancelacionDeadlineYTransporte(t *testing.T) { c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: s.URL, ClienteHTTP: s.Client()}) ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := c.CrearSesion(ctx, solicitudSEPPrueba()); !errors.Is(err, context.Canceled) { + if _, err := c.CrearSesion(ctx); !errors.Is(err, context.Canceled) { t.Fatalf("cancelación %v", err) } ctx, cancel = context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() - if _, err := c.CrearBootstrap(ctx, solicitudSEPPrueba()); !errors.Is(err, context.DeadlineExceeded) { + if _, err := c.CrearBootstrap(ctx); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("deadline %v", err) } var llamadas atomic.Int32 @@ -214,7 +180,7 @@ func TestSEPCancelacionDeadlineYTransporte(t *testing.T) { llamadas.Add(1) return nil, fmt.Errorf("fallo con %s", claveSEPPrueba) })}}) - _, err := c.CrearBootstrap(context.Background(), solicitudSEPPrueba()) + _, err := c.CrearBootstrap(context.Background()) if err == nil || strings.Contains(err.Error(), claveSEPPrueba) || llamadas.Load() != 1 { t.Fatal("error transporte inseguro o reintentos") } @@ -225,14 +191,14 @@ func TestSEPRespuestaInvalidaYErrorHTTP(t *testing.T) { c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: "https://example.test", ClienteHTTP: &http.Client{Transport: roundTripperPrueba(func(r *http.Request) (*http.Response, error) { return &http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: r}, nil })}}) - if _, err := c.CrearBootstrap(context.Background(), solicitudSEPPrueba()); err == nil || strings.Contains(err.Error(), "secreto-malformado") { + if _, err := c.CrearBootstrap(context.Background()); err == nil || strings.Contains(err.Error(), "secreto-malformado") { t.Fatal("respuesta inválida o error inseguro") } } c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: "https://example.test", ClienteHTTP: &http.Client{Transport: roundTripperPrueba(func(r *http.Request) (*http.Response, error) { return &http.Response{StatusCode: 502, Body: io.NopCloser(strings.NewReader(claveSEPPrueba + tokenSEPPrueba)), Header: make(http.Header)}, nil })}}) - _, err := c.CrearBootstrap(context.Background(), solicitudSEPPrueba()) + _, err := c.CrearBootstrap(context.Background()) var httpErr *ErrorHTTP if !errors.As(err, &httpErr) || httpErr.Estado != 502 || strings.Contains(httpErr.Cuerpo, claveSEPPrueba) { t.Fatal("error HTTP inseguro") @@ -250,7 +216,7 @@ func TestSEPDeadlineDuranteLecturaDelCuerpo(t *testing.T) { c, _ := NuevoClienteSEP(OpcionesClienteSEP{ClaveIntegracion: claveSEPPrueba, URLBase: s.URL, ClienteHTTP: s.Client()}) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - _, err := c.CrearBootstrap(ctx, solicitudSEPPrueba()) + _, err := c.CrearBootstrap(ctx) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("deadline leyendo respuesta: %v", err) } diff --git a/transporte_sep.go b/transporte_sep.go index ea62e0c..ad00aab 100644 --- a/transporte_sep.go +++ b/transporte_sep.go @@ -1,7 +1,6 @@ package gofip import ( - "bytes" "context" "encoding/json" "errors" @@ -9,24 +8,17 @@ import ( "io" "net/http" "regexp" - - "github.com/unkcode-org/gofip-sdk-go/v2/modelos/sep" ) var patronCodigoSEP = regexp.MustCompile(`^[0-9]{5}$`) -func (c *ClienteSEP) post(ctx context.Context, ruta string, solicitud sep.SolicitudCrearSesionEmbedded, destino any) error { - contenido, err := json.Marshal(cuerpoDatos[sep.SolicitudCrearSesionEmbedded]{Datos: solicitud}) - if err != nil { - return errors.New("serializar solicitud SEP") - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.urlBase+ruta, bytes.NewReader(contenido)) +func (c *ClienteSEP) post(ctx context.Context, ruta string, destino any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.urlBase+ruta, nil) if err != nil { return errors.New("crear solicitud SEP") } req.Header.Set("Authorization", "IntegrationKey "+c.clave) req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") // Sin Idempotency-Key y sin política de reintentos: estos POST crean capacidades. resp, err := c.cliente.Do(req) if err != nil {