Commit 2c46fb2

mo khan <mo@mokhan.ca>
2026-07-27 14:31:13
feat: add --validate option to validate endpoints against JSON schema
cli
1 parent 52cf40b
lib/scim/kit/cli/schemas/list_response.schema.json
@@ -0,0 +1,13 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "schemas": { "type": "array", "items": { "type": "string" } },
+    "totalResults": { "type": "integer" },
+    "itemsPerPage": { "type": "integer" },
+    "startIndex": { "type": "integer" },
+    "Resources": { "type": "array", "items": {} }
+  },
+  "required": ["schemas", "totalResults"],
+  "additionalProperties": false
+}
lib/scim/kit/cli/schemas/resource_types.schema.json
@@ -0,0 +1,42 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "array",
+  "items": {
+    "type": "object",
+    "properties": {
+      "schemas": { "type": "array", "items": { "type": "string" } },
+      "id": { "type": "string" },
+      "name": { "type": "string" },
+      "description": { "type": "string" },
+      "endpoint": { "type": "string" },
+      "schema": { "type": "string" },
+      "schemaExtensions": {
+        "type": "array",
+        "items": {
+          "type": "object",
+          "properties": {
+            "schema": { "type": "string" },
+            "required": { "type": "boolean" }
+          },
+          "required": ["schema", "required"],
+          "additionalProperties": false
+        }
+      },
+      "externalId": { "type": "string" },
+      "meta": {
+        "type": "object",
+        "properties": {
+          "resourceType": { "type": "string" },
+          "created": { "type": "string", "format": "date-time" },
+          "lastModified": { "type": "string", "format": "date-time" },
+          "location": { "type": "string" },
+          "version": { "type": "string" }
+        },
+        "required": [],
+        "additionalProperties": false
+      }
+    },
+    "required": ["schemas", "name", "endpoint", "schema"],
+    "additionalProperties": false
+  }
+}
lib/scim/kit/cli/schemas/schemas.schema.json
@@ -0,0 +1,72 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "array",
+  "items": {
+    "type": "object",
+    "properties": {
+      "schemas": { "type": "array", "items": { "type": "string" } },
+      "id": { "type": "string" },
+      "name": { "type": "string" },
+      "description": { "type": "string" },
+      "attributes": {
+        "type": "array",
+        "items": { "$ref": "#/$defs/attribute" }
+      },
+      "externalId": { "type": "string" },
+      "meta": { "$ref": "#/$defs/meta" }
+    },
+    "required": ["schemas", "id", "attributes"],
+    "additionalProperties": false
+  },
+  "$defs": {
+    "attribute": {
+      "type": "object",
+      "properties": {
+        "name": { "type": "string" },
+        "type": {
+          "type": "string",
+          "enum": [
+            "string", "boolean", "decimal", "integer", "dateTime",
+            "reference", "complex"
+          ]
+        },
+        "multiValued": { "type": "boolean" },
+        "description": { "type": "string" },
+        "required": { "type": "boolean" },
+        "canonicalValues": { "type": "array" },
+        "caseExact": { "type": "boolean" },
+        "mutability": {
+          "type": "string",
+          "enum": ["readOnly", "readWrite", "immutable", "writeOnly"]
+        },
+        "returned": {
+          "type": "string",
+          "enum": ["always", "never", "default", "request"]
+        },
+        "uniqueness": {
+          "type": "string",
+          "enum": ["none", "server", "global"]
+        },
+        "referenceTypes": { "type": "array", "items": { "type": "string" } },
+        "subAttributes": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/attribute" }
+        }
+      },
+      "required": ["name", "type"],
+      "additionalProperties": false
+    },
+    "meta": {
+      "type": "object",
+      "properties": {
+        "resourceType": { "type": "string" },
+        "created": { "type": "string", "format": "date-time" },
+        "lastModified": { "type": "string", "format": "date-time" },
+        "location": { "type": "string" },
+        "version": { "type": "string" }
+      },
+      "required": [],
+      "additionalProperties": false
+    }
+  }
+}
lib/scim/kit/cli/schemas/service_provider_config.schema.json
@@ -0,0 +1,85 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "schemas": { "type": "array", "items": { "type": "string" } },
+    "id": { "type": "string" },
+    "externalId": { "type": "string" },
+    "documentationUri": { "type": "string" },
+    "patch": {
+      "type": "object",
+      "properties": { "supported": { "type": "boolean" } },
+      "required": ["supported"],
+      "additionalProperties": false
+    },
+    "bulk": {
+      "type": "object",
+      "properties": {
+        "supported": { "type": "boolean" },
+        "maxOperations": { "type": "integer" },
+        "maxPayloadSize": { "type": "integer" }
+      },
+      "required": ["supported", "maxOperations", "maxPayloadSize"],
+      "additionalProperties": false
+    },
+    "filter": {
+      "type": "object",
+      "properties": {
+        "supported": { "type": "boolean" },
+        "maxResults": { "type": "integer" }
+      },
+      "required": ["supported", "maxResults"],
+      "additionalProperties": false
+    },
+    "changePassword": {
+      "type": "object",
+      "properties": { "supported": { "type": "boolean" } },
+      "required": ["supported"],
+      "additionalProperties": false
+    },
+    "sort": {
+      "type": "object",
+      "properties": { "supported": { "type": "boolean" } },
+      "required": ["supported"],
+      "additionalProperties": false
+    },
+    "etag": {
+      "type": "object",
+      "properties": { "supported": { "type": "boolean" } },
+      "required": ["supported"],
+      "additionalProperties": false
+    },
+    "authenticationSchemes": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "properties": {
+          "type": { "type": "string" },
+          "name": { "type": "string" },
+          "description": { "type": "string" },
+          "specUri": { "type": "string" },
+          "documentationUri": { "type": "string" }
+        },
+        "required": ["type", "name", "description"],
+        "additionalProperties": false
+      }
+    },
+    "meta": {
+      "type": "object",
+      "properties": {
+        "resourceType": { "type": "string" },
+        "created": { "type": "string", "format": "date-time" },
+        "lastModified": { "type": "string", "format": "date-time" },
+        "location": { "type": "string" },
+        "version": { "type": "string" }
+      },
+      "required": [],
+      "additionalProperties": false
+    }
+  },
+  "required": [
+    "schemas", "patch", "bulk", "filter", "changePassword", "sort", "etag",
+    "authenticationSchemes"
+  ],
+  "additionalProperties": false
+}
lib/scim/kit/cli/app.rb
@@ -17,6 +17,8 @@ module Scim
         class_option :url, desc: 'Base URL of the SCIM server (or SCIM_KIT_URL)'
         class_option :header, type: :array, default: [],
           desc: 'Extra header as "Name: Value" (repeatable)'
+        class_option :validate, type: :boolean, default: false,
+          desc: 'Validate the response against a JSON Schema'
 
         desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
         def discover
@@ -43,21 +45,51 @@ module Scim
         private
 
         def fetch_discovery
+          responses, error = fetch_discovery_responses
+          return Reporting.report(error, shell) if error
+
+          combined = Http::Result.new(200, responses)
+          report_discovery(combined, responses)
+        end
+
+        def fetch_discovery_responses
           responses = {}
           RESOURCES.each do |key, path|
-            result = http.fetch(Cli.join_uri(url, path), headers: headers)
-            return Reporting.report(result, shell) unless result.ok?
+            uri = Cli.join_uri(url, path)
+            result = http.fetch(uri, headers: headers)
+            return [responses, result] unless result.ok?
 
             responses[key] = result.body
           end
-          Reporting.report(Http::Result.new(200, responses), shell)
+          [responses, nil]
+        end
+
+        def report_discovery(combined, responses)
+          if options[:validate]
+            Reporting.report_with_validation(
+              combined, shell, discovery_errors(responses)
+            )
+          else
+            Reporting.report(combined, shell)
+          end
+        end
+
+        def discovery_errors(responses)
+          responses.each_with_object({}) do |(key, body), errors|
+            schema = SchemaRegistry.fetch(key)
+            body_errors = Validator.errors_for(schema, body)
+            errors[key] = body_errors unless body_errors.empty?
+          end
         end
 
         def fetch_list(resource_type)
           uri = Cli.join_uri(url, resolve_endpoint(resource_type))
           query = list_query_string
           uri.query = query unless query.empty?
-          report_fetch(uri)
+          result = http.fetch(uri, headers: headers)
+          validate_and_report(result, resource_type) do |schema|
+            SchemaRegistry.list_response_with_items(schema)
+          end
         end
 
         def fetch_resource(resource_type, id)
@@ -65,17 +97,48 @@ module Scim
           if options[:attributes]
             uri.query = URI.encode_www_form(attributes: options[:attributes])
           end
-          report_fetch(uri)
+          result = http.fetch(uri, headers: headers)
+          validate_and_report(result, resource_type) { |schema| schema }
         end
 
-        def report_fetch(uri)
-          Reporting.report(http.fetch(uri, headers: headers), shell)
+        def resolve_endpoint(resource_type)
+          endpoint = resource_type_entry(resource_type)[:endpoint]
+          raise MissingEndpoint, resource_type if endpoint.to_s.empty?
+
+          endpoint.delete_prefix('/')
         end
 
-        def resolve_endpoint(resource_type)
-          ResourceTypeResolver.new(http, url, headers: headers)
-            .endpoint_for(resource_type)
-            .delete_prefix('/')
+        def resource_type_entry(resource_type)
+          @resource_type_entry ||=
+            resource_type_resolver.resource_type_for(resource_type)
+        end
+
+        def resource_type_resolver
+          @resource_type_resolver ||= ResourceTypeResolver.new(
+            http, url, headers: headers
+          )
+        end
+
+        def resource_schema_resolver
+          @resource_schema_resolver ||= ResourceSchemaResolver.new(
+            http, url, headers: headers
+          )
+        end
+
+        def validate_and_report(result, resource_type)
+          unless result.ok? && options[:validate]
+            return Reporting.report(result, shell)
+          end
+
+          entry = resource_type_entry(resource_type)
+          schema = resource_schema_resolver.schema_for(entry)
+          unless schema
+            Reporting.warn_unresolvable_schema(resource_type, shell)
+            return Reporting.report(result, shell)
+          end
+
+          errors = Validator.errors_for(yield(schema), result.body)
+          Reporting.report_with_validation(result, shell, errors)
         end
 
         def list_query_string
lib/scim/kit/cli/reporting.rb
@@ -19,6 +19,25 @@ module Scim
           exit(1)
         end
 
+        def self.report_with_validation(result, shell, errors)
+          shell.say(JSON.pretty_generate(result.body))
+          if errors.empty?
+            exit(0)
+          else
+            shell.say_error(
+              JSON.pretty_generate(validation_errors: errors)
+            )
+            exit(1)
+          end
+        end
+
+        def self.warn_unresolvable_schema(resource_type, shell)
+          shell.say_error(
+            'warning: no schema found for resource type ' \
+            "#{resource_type.inspect}; skipping validation"
+          )
+        end
+
         def self.rescue_errors(shell)
           yield
         rescue RequestFailed => error
lib/scim/kit/cli/resource_schema_resolver.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class ResourceSchemaResolver
+        COMMON_PROPERTIES = {
+          'schemas' => {
+            'type' => 'array', 'items' => { 'type' => 'string' }
+          },
+          'id' => { 'type' => 'string' },
+          'externalId' => { 'type' => 'string' },
+          'meta' => {
+            'type' => 'object',
+            'properties' => {
+              'resourceType' => { 'type' => 'string' },
+              'created' => { 'type' => 'string', 'format' => 'date-time' },
+              'lastModified' => {
+                'type' => 'string', 'format' => 'date-time'
+              },
+              'location' => { 'type' => 'string' },
+              'version' => { 'type' => 'string' }
+            },
+            'required' => [],
+            'additionalProperties' => false
+          }
+        }.freeze
+
+        def initialize(http, base_url, headers: {})
+          @http = http
+          @base_url = base_url
+          @headers = headers
+        end
+
+        def schema_for(resource_type)
+          schemas = fetch_schemas(urns_for(resource_type))
+          core = schemas[resource_type[:schema]]
+          return nil if core.nil?
+
+          compose(resource_type, schemas, core)
+        end
+
+        private
+
+        attr_reader :http, :base_url, :headers
+
+        def urns_for(resource_type)
+          extensions = Array(resource_type[:schemaExtensions])
+          [
+            resource_type[:schema],
+            *extensions.map { |e| e[:schema] }
+          ].compact
+        end
+
+        def compose(resource_type, schemas, core)
+          converted = ScimSchemaConverter.convert(core)
+          properties = COMMON_PROPERTIES.merge(converted['properties'])
+          required = converted['required']
+          merge_extensions(
+            resource_type, schemas, properties, required
+          )
+          build_schema(properties, required)
+        end
+
+        def build_schema(properties, required)
+          {
+            'type' => 'object',
+            'properties' => properties,
+            'required' => required,
+            'additionalProperties' => false
+          }
+        end
+
+        def merge_extensions(resource_type, schemas, properties, required)
+          Array(resource_type[:schemaExtensions]).each do |extension|
+            extension_schema = schemas[extension[:schema]]
+            next unless extension_schema
+
+            properties[extension[:schema]] =
+              ScimSchemaConverter.convert(extension_schema)
+            required << extension[:schema] if extension[:required]
+          end
+        end
+
+        def fetch_schemas(urns)
+          uri = Cli.join_uri(base_url, 'Schemas')
+          result = http.fetch(uri, headers: headers)
+          return {} unless result.ok? && result.body.is_a?(Array)
+
+          result.body.each_with_object({}) do |schema, hash|
+            hash[schema[:id]] = schema if urns.include?(schema[:id])
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/resource_type_resolver.rb
@@ -10,14 +10,18 @@ module Scim
           @headers = headers
         end
 
-        def endpoint_for(name)
+        def resource_type_for(name)
           types = resource_types
           match = types.find { |x| matches?(x, name) }
           unless match
             raise UnknownResourceType.new(name, types.map { |x| x[:name] })
           end
 
-          endpoint = match[:endpoint]
+          match
+        end
+
+        def endpoint_for(name)
+          endpoint = resource_type_for(name)[:endpoint]
           raise MissingEndpoint, name if endpoint.to_s.empty?
 
           endpoint
lib/scim/kit/cli/schema_registry.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      module SchemaRegistry
+        DIR = File.expand_path('schemas', __dir__)
+
+        FILES = {
+          service_provider_configuration:
+            'service_provider_config.schema.json',
+          schemas: 'schemas.schema.json',
+          resource_types: 'resource_types.schema.json'
+        }.freeze
+
+        def self.fetch(key)
+          @cache ||= {}
+          @cache[key] ||= load(FILES.fetch(key))
+        end
+
+        def self.list_response_with_items(resource_schema)
+          schema = load('list_response.schema.json')
+          schema['properties']['Resources']['items'] = resource_schema
+          schema
+        end
+
+        def self.load(file_name)
+          JSON.parse(File.read(File.join(DIR, file_name)))
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/scim_schema_converter.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      module ScimSchemaConverter
+        TYPE_MAP = {
+          'string' => { 'type' => 'string' },
+          'reference' => { 'type' => 'string' },
+          'binary' => { 'type' => 'string' },
+          'boolean' => { 'type' => 'boolean' },
+          'decimal' => { 'type' => 'number' },
+          'integer' => { 'type' => 'integer' },
+          'dateTime' => { 'type' => 'string', 'format' => 'date-time' }
+        }.freeze
+
+        def self.convert(schema)
+          object_schema(schema[:attributes] || [])
+        end
+
+        def self.object_schema(attributes)
+          properties, required = attributes.reduce([{}, []]) do |(p, r), a|
+            p[a[:name]] = attribute_schema(a)
+            r << a[:name] if a[:required]
+            [p, r]
+          end
+          { 'type' => 'object', 'properties' => properties,
+            'required' => required, 'additionalProperties' => false }
+        end
+
+        def self.attribute_schema(attribute)
+          schema = leaf_schema(attribute)
+          return schema unless attribute[:multiValued]
+
+          { 'type' => 'array', 'items' => schema }
+        end
+
+        def self.leaf_schema(attribute)
+          if attribute[:type] == 'complex'
+            object_schema(attribute[:subAttributes] || [])
+          else
+            with_enum(attribute)
+          end
+        end
+
+        def self.with_enum(attribute)
+          schema = TYPE_MAP.fetch(attribute[:type]).dup
+          canonical_values = attribute[:canonicalValues]
+          return schema if canonical_values.nil? || canonical_values.empty?
+
+          schema.merge('enum' => canonical_values)
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/validator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      module Validator
+        def self.errors_for(schema, data)
+          JSONSchemer.schema(schema)
+            .validate(normalize(data))
+            .map { |error| JSONSchemer::Errors.pretty(error) }
+        end
+
+        def self.normalize(data)
+          JSON.parse(JSON.generate(data))
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli.rb
@@ -4,6 +4,11 @@ require 'thor'
 
 require 'scim/kit/cli/reporting'
 require 'scim/kit/cli/resource_type_resolver'
+require 'scim/kit/cli/scim_schema_converter'
+require 'json_schemer'
+require 'scim/kit/cli/schema_registry'
+require 'scim/kit/cli/validator'
+require 'scim/kit/cli/resource_schema_resolver'
 require 'scim/kit/cli/app'
 
 module Scim
spec/scim/kit/cli/app_spec.rb
@@ -90,6 +90,94 @@ RSpec.describe Scim::Kit::Cli::App do
         expect(exit_status { app.discover }).to eq(1)
       end
     end
+
+    context 'when --validate is set and every document is valid' do
+      let(:service_provider_configuration) do
+        {
+          schemas: [
+            'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'
+          ],
+          patch: { supported: true },
+          bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+          filter: { supported: true, maxResults: 200 },
+          changePassword: { supported: false },
+          sort: { supported: false },
+          etag: { supported: false },
+          authenticationSchemes: [
+            {
+              type: 'httpbasic', name: 'HTTP Basic',
+              description: 'basic auth'
+            }
+          ]
+        }
+      end
+      let(:schemas) do
+        [
+          {
+            id: 'urn:ietf:params:scim:schemas:core:2.0:User',
+            schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
+            attributes: [
+              { name: 'userName', type: 'string', required: true }
+            ]
+          }
+        ]
+      end
+      let(:resource_types) do
+        [
+          {
+            schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
+            name: 'User', endpoint: '/Users',
+            schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
+          }
+        ]
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
+          status: 200, body: service_provider_configuration.to_json
+        )
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas.to_json)
+        stub_request(:get, "#{base_url}/ResourceTypes")
+          .to_return(status: 200, body: resource_types.to_json)
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+        instance = app('validate' => true)
+
+        expect(exit_status { instance.discover }).to eq(0)
+      end
+    end
+
+    context 'when --validate is set and a document is invalid' do
+      let(:service_provider_configuration) { { patch: { supported: true } } }
+      let(:schemas) { [{ id: 'User', name: 'User' }] }
+
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
+          status: 200, body: service_provider_configuration.to_json
+        )
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas.to_json)
+      end
+
+      it 'prints validation errors to stderr' do
+        allow($stdout).to receive(:print)
+        instance = app('validate' => true)
+
+        expect { exit_status { instance.discover } }
+          .to output(/validation_errors/).to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stdout).to receive(:print)
+        allow($stderr).to receive(:print)
+        instance = app('validate' => true)
+
+        expect(exit_status { instance.discover }).to eq(1)
+      end
+    end
   end
 
   describe '#list' do
@@ -152,6 +240,109 @@ RSpec.describe Scim::Kit::Cli::App do
         expect(exit_status { app.list('User') }).to eq(1)
       end
     end
+
+    context 'when --validate is set' do
+      let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+      let(:resource_types) do
+        [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
+      end
+      let(:schemas_response) do
+        [
+          {
+            id: core_urn,
+            attributes: [
+              { name: 'userName', type: 'string', required: true }
+            ]
+          }
+        ]
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas_response.to_json)
+      end
+
+      context 'when the list response is valid' do
+        let(:list_response) do
+          {
+            schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+            totalResults: 1, Resources: [{ id: '1', userName: 'bjensen' }]
+          }
+        end
+
+        before do
+          stub_request(:get, "#{base_url}/Users")
+            .to_return(status: 200, body: list_response.to_json)
+        end
+
+        it 'exits 0' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.list('User') }).to eq(0)
+        end
+      end
+
+      context 'when a resource in the list response is invalid' do
+        let(:list_response) do
+          {
+            schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+            totalResults: 1, Resources: [{ id: '1', userName: 42 }]
+          }
+        end
+
+        before do
+          stub_request(:get, "#{base_url}/Users")
+            .to_return(status: 200, body: list_response.to_json)
+        end
+
+        it 'prints validation errors to stderr' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect { exit_status { instance.list('User') } }
+            .to output(/validation_errors/).to_stderr
+        end
+
+        it 'exits 1' do
+          allow($stdout).to receive(:print)
+          allow($stderr).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.list('User') }).to eq(1)
+        end
+      end
+
+      context 'when the resource type has no resolvable schema' do
+        let(:resource_types) do
+          [
+            { id: 'User', name: 'User', endpoint: '/Users',
+              schema: 'urn:example:Unresolvable' }
+          ]
+        end
+
+        before do
+          stub_request(:get, "#{base_url}/Users")
+            .to_return(status: 200, body: { totalResults: 0 }.to_json)
+        end
+
+        it 'warns and exits 0' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect { exit_status { instance.list('User') } }
+            .to output(/no schema found/).to_stderr
+        end
+
+        it 'exits 0' do
+          allow($stdout).to receive(:print)
+          allow($stderr).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.list('User') }).to eq(0)
+        end
+      end
+    end
   end
 
   describe '#get' do
@@ -196,6 +387,67 @@ RSpec.describe Scim::Kit::Cli::App do
         expect(exit_status { app.get('User', '123') }).to eq(1)
       end
     end
+
+    context 'when --validate is set' do
+      let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+      let(:resource_types) do
+        [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
+      end
+      let(:schemas_response) do
+        [
+          {
+            id: core_urn,
+            attributes: [
+              { name: 'userName', type: 'string', required: true }
+            ]
+          }
+        ]
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas_response.to_json)
+      end
+
+      context 'when the resource is valid' do
+        before do
+          stub_request(:get, "#{base_url}/Users/123").to_return(
+            status: 200, body: { id: '123', userName: 'bjensen' }.to_json
+          )
+        end
+
+        it 'exits 0' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.get('User', '123') }).to eq(0)
+        end
+      end
+
+      context 'when the resource is invalid' do
+        before do
+          stub_request(:get, "#{base_url}/Users/123").to_return(
+            status: 200, body: { id: '123', userName: 42 }.to_json
+          )
+        end
+
+        it 'prints validation errors to stderr' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect { exit_status { instance.get('User', '123') } }
+            .to output(/validation_errors/).to_stderr
+        end
+
+        it 'exits 1' do
+          allow($stdout).to receive(:print)
+          allow($stderr).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.get('User', '123') }).to eq(1)
+        end
+      end
+    end
   end
 
   describe 'header parsing' do
spec/scim/kit/cli/reporting_spec.rb
@@ -48,6 +48,57 @@ RSpec.describe Scim::Kit::Cli::Reporting do
     end
   end
 
+  describe '.report_with_validation' do
+    let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
+
+    context 'when there are no errors' do
+      it 'prints the body as pretty json to stdout' do
+        expect do
+          exit_status do
+            described_class.report_with_validation(result, shell, [])
+          end
+        end.to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+
+        status = exit_status do
+          described_class.report_with_validation(result, shell, [])
+        end
+
+        expect(status).to eq(0)
+      end
+    end
+
+    context 'when there are errors' do
+      let(:errors) { ['root is missing required keys: userName'] }
+
+      it 'prints the body to stdout and the errors to stderr' do
+        allow($stdout).to receive(:print)
+
+        expect { exit_status { described_class.report_with_validation(result, shell, errors) } }
+          .to output("#{JSON.pretty_generate(validation_errors: errors)}\n").to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stdout).to receive(:print)
+        allow($stderr).to receive(:print)
+
+        status = exit_status { described_class.report_with_validation(result, shell, errors) }
+
+        expect(status).to eq(1)
+      end
+    end
+  end
+
+  describe '.warn_unresolvable_schema' do
+    it 'prints a warning mentioning the resource type to stderr' do
+      expect { described_class.warn_unresolvable_schema('Foo', shell) }
+        .to output(/no schema found for resource type "Foo"/).to_stderr
+    end
+  end
+
   describe '.rescue_errors' do
     it 'returns the value of the block when nothing is raised' do
       expect(described_class.rescue_errors(shell) { 'ok' }).to eq('ok')
spec/scim/kit/cli/resource_schema_resolver_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
+  subject { described_class.new(Scim::Kit::Http.new, base_url, headers: {}) }
+
+  let(:base_url) { FFaker::Internet.uri('https') }
+  let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+  let(:extension_urn) do
+    'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
+  end
+
+  let(:resource_type) do
+    {
+      id: 'User', name: 'User', endpoint: '/Users', schema: core_urn,
+      schemaExtensions: [{ schema: extension_urn, required: true }]
+    }
+  end
+
+  let(:core_schema) do
+    {
+      id: core_urn,
+      attributes: [{ name: 'userName', type: 'string', required: true }]
+    }
+  end
+
+  let(:extension_schema) do
+    {
+      id: extension_urn,
+      attributes: [{ name: 'employeeNumber', type: 'string' }]
+    }
+  end
+
+  describe '#schema_for' do
+    context 'when the core and extension schemas are both found' do
+      let(:schema) { subject.schema_for(resource_type) }
+      let(:expected_extension_schema) do
+        {
+          'type' => 'object',
+          'properties' => { 'employeeNumber' => { 'type' => 'string' } },
+          'required' => [],
+          'additionalProperties' => false
+        }
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas").to_return(
+          status: 200, body: [core_schema, extension_schema].to_json
+        )
+      end
+
+      it 'includes the core attributes at the top level' do
+        expect(schema['properties']['userName']).to eql('type' => 'string')
+      end
+
+      it 'includes userName in the required list' do
+        expect(schema['required']).to include('userName')
+      end
+
+      it 'includes common attributes' do
+        expect(schema['properties']).to include('id', 'meta', 'schemas')
+      end
+
+      it 'nests extension attributes under the extension URN' do
+        expect(schema['properties'][extension_urn]).to eql(expected_extension_schema)
+      end
+
+      it 'requires the extension URN when the extension is required' do
+        expect(schema['required']).to include(extension_urn)
+      end
+
+      it 'disallows undeclared top-level properties' do
+        expect(schema['additionalProperties']).to be(false)
+      end
+    end
+
+    context 'when the extension schema is not found' do
+      let(:schema) { subject.schema_for(resource_type) }
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: [core_schema].to_json)
+      end
+
+      it 'includes the core attributes' do
+        expect(schema['properties']).to include('userName')
+      end
+
+      it 'excludes the extension URN from the properties' do
+        expect(schema['properties']).not_to include(extension_urn)
+      end
+    end
+
+    context 'when the core schema is not found' do
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: [extension_schema].to_json)
+      end
+
+      it 'returns nil' do
+        expect(subject.schema_for(resource_type)).to be_nil
+      end
+    end
+
+    context 'when the /Schemas request fails' do
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 500, body: '{}')
+      end
+
+      it 'returns nil' do
+        expect(subject.schema_for(resource_type)).to be_nil
+      end
+    end
+
+    context 'when the resource type has no extensions' do
+      let(:resource_type) do
+        { id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: [core_schema].to_json)
+      end
+
+      it 'returns a schema without extension properties' do
+        schema = subject.schema_for(resource_type)
+
+        expect(schema['properties'].keys).to match_array(
+          %w[schemas id externalId meta userName]
+        )
+      end
+    end
+  end
+end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -38,6 +38,31 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
     end
   end
 
+  describe '#resource_type_for' do
+    before do
+      stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+        status: 200,
+        body: [
+          { id: 'User', name: 'User', endpoint: '/Users',
+            schema: 'urn:ietf:params:scim:schemas:core:2.0:User' }
+        ].to_json
+      )
+    end
+
+    it 'returns the full matched resource type entry' do
+      expect(subject.resource_type_for('User')).to include(
+        endpoint: '/Users',
+        schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
+      )
+    end
+
+    it 'raises when no resource type matches the given name' do
+      expect { subject.resource_type_for('Nope') }.to raise_error(
+        Scim::Kit::Cli::UnknownResourceType, /Nope/
+      )
+    end
+  end
+
   context 'when the matched resource type has no endpoint' do
     before do
       stub_request(:get, "#{base_url}/ResourceTypes").to_return(
spec/scim/kit/cli/schema_registry_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::SchemaRegistry do
+  describe '.fetch' do
+    it 'loads the service_provider_configuration schema' do
+      schema = described_class.fetch(:service_provider_configuration)
+
+      expect(schema['required']).to include('patch', 'bulk')
+    end
+
+    it 'loads the resource_types schema' do
+      schema = described_class.fetch(:resource_types)
+
+      expect(schema['items']['required']).to include('endpoint')
+    end
+
+    it 'loads the schemas schema' do
+      schema = described_class.fetch(:schemas)
+
+      expect(schema['items']['required']).to include('attributes')
+    end
+  end
+
+  describe '.list_response_with_items' do
+    it 'injects the given schema as the Resources items schema' do
+      item_schema = { 'type' => 'object' }
+
+      schema = described_class.list_response_with_items(item_schema)
+
+      expect(schema['properties']['Resources']['items']).to eql(item_schema)
+    end
+
+    it 'returns independent schema objects across calls' do
+      first = described_class.list_response_with_items({ 'type' => 'object' })
+      first['properties']['Resources']['items']['type'] = 'mutated'
+
+      second = described_class.list_response_with_items({ 'type' => 'string' })
+
+      expect(second['properties']['Resources']['items']).to eql('type' => 'string')
+    end
+  end
+
+  describe 'built-in schemas are valid JSON Schema documents' do
+    it 'validates service_provider_configuration' do
+      schema = described_class.fetch(:service_provider_configuration)
+
+      expect(JSONSchemer.valid_schema?(schema)).to be(true)
+    end
+
+    it 'validates resource_types' do
+      schema = described_class.fetch(:resource_types)
+
+      expect(JSONSchemer.valid_schema?(schema)).to be(true)
+    end
+
+    it 'validates schemas' do
+      schema = described_class.fetch(:schemas)
+
+      expect(JSONSchemer.valid_schema?(schema)).to be(true)
+    end
+
+    it 'validates list_response' do
+      schema = described_class.list_response_with_items({})
+
+      expect(JSONSchemer.valid_schema?(schema)).to be(true)
+    end
+  end
+end
spec/scim/kit/cli/scim_schema_converter_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
+  describe '.convert' do
+    context 'with a string attribute' do
+      let(:schema) { { attributes: [{ name: 'userName', type: 'string' }] } }
+      let(:expected_schema) do
+        {
+          'type' => 'object',
+          'properties' => { 'userName' => { 'type' => 'string' } },
+          'required' => [],
+          'additionalProperties' => false
+        }
+      end
+
+      it 'maps a string attribute' do
+        result = described_class.convert(schema)
+
+        expect(result).to eql(expected_schema)
+      end
+    end
+
+    context 'with a required attribute' do
+      let(:schema) do
+        {
+          attributes: [
+            { name: 'userName', type: 'string', required: true }
+          ]
+        }
+      end
+
+      it 'marks required attributes' do
+        result = described_class.convert(schema)
+
+        expect(result['required']).to eql(['userName'])
+      end
+    end
+
+    context 'with a multiValued attribute' do
+      let(:schema) do
+        {
+          attributes: [
+            { name: 'emails', type: 'string', multiValued: true }
+          ]
+        }
+      end
+
+      it 'wraps multiValued attributes in an array schema' do
+        result = described_class.convert(schema)
+
+        expect(result['properties']['emails']).to eql('type' => 'array', 'items' => { 'type' => 'string' })
+      end
+    end
+
+    context 'with canonicalValues' do
+      let(:schema) do
+        {
+          attributes: [
+            { name: 'type', type: 'string', canonicalValues: %w[work home] }
+          ]
+        }
+      end
+
+      it 'maps canonicalValues to enum' do
+        result = described_class.convert(schema)
+
+        expect(result['properties']['type']).to eql('type' => 'string', 'enum' => %w[work home])
+      end
+    end
+
+    context 'with a complex attribute containing subAttributes' do
+      let(:schema) do
+        {
+          attributes: [
+            {
+              name: 'name', type: 'complex',
+              subAttributes: [
+                { name: 'givenName', type: 'string', required: true },
+                { name: 'familyName', type: 'string' }
+              ]
+            }
+          ]
+        }
+      end
+      let(:expected_name_schema) do
+        {
+          'type' => 'object',
+          'properties' => {
+            'givenName' => { 'type' => 'string' },
+            'familyName' => { 'type' => 'string' }
+          },
+          'required' => ['givenName'],
+          'additionalProperties' => false
+        }
+      end
+
+      it 'maps complex attributes with nested subAttributes' do
+        result = described_class.convert(schema)
+
+        expect(result['properties']['name']).to eql(expected_name_schema)
+      end
+    end
+
+    context 'with every scalar SCIM type' do
+      let(:types) do
+        {
+          'boolean' => { 'type' => 'boolean' },
+          'decimal' => { 'type' => 'number' },
+          'integer' => { 'type' => 'integer' },
+          'dateTime' => { 'type' => 'string', 'format' => 'date-time' },
+          'reference' => { 'type' => 'string' },
+          'binary' => { 'type' => 'string' }
+        }
+      end
+
+      it 'maps every scalar SCIM type to its JSON Schema equivalent' do
+        types.each do |scim_type, json_schema|
+          schema = { attributes: [{ name: 'x', type: scim_type }] }
+          result = described_class.convert(schema)
+
+          expect(result['properties']['x']).to eql(json_schema)
+        end
+      end
+    end
+
+    it 'defaults to no attributes when attributes is missing' do
+      result = described_class.convert({})
+
+      expect(result['properties']).to eql({})
+    end
+  end
+end
spec/scim/kit/cli/validator_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Validator do
+  describe '.errors_for' do
+    let(:schema) do
+      {
+        'type' => 'object',
+        'properties' => { 'userName' => { 'type' => 'string' } },
+        'required' => ['userName'],
+        'additionalProperties' => false
+      }
+    end
+
+    it 'returns an empty array for a valid document' do
+      errors = described_class.errors_for(schema, { userName: 'bjensen' })
+
+      expect(errors).to eql([])
+    end
+
+    it 'returns a readable error for a missing required property' do
+      errors = described_class.errors_for(schema, {})
+
+      expect(errors).to eql(['root is missing required keys: userName'])
+    end
+
+    it 'returns a readable error for a wrong type' do
+      errors = described_class.errors_for(schema, { userName: 1 })
+
+      expect(errors).to eql(["property '/userName' is not of type: string"])
+    end
+
+    it 'returns a readable error for an undeclared property' do
+      errors = described_class.errors_for(schema, { userName: 'bjensen', extra: true })
+
+      expect(errors).to eql(["property '/extra' is invalid: error_type=schema"])
+    end
+  end
+end
.rubocop.yml
@@ -51,6 +51,7 @@ Lint/StructNewOverride:
 Metrics/AbcSize:
   Exclude:
     - 'lib/scim/kit/v2/service_provider_configuration.rb'
+    - 'lib/scim/kit/cli/app.rb'
 
 Metrics/BlockLength:
   Exclude:
@@ -58,6 +59,14 @@ Metrics/BlockLength:
     - 'Rakefile'
     - 'spec/**/*.rb'
 
+Metrics/ClassLength:
+  Exclude:
+    - 'lib/scim/kit/cli/app.rb'
+
+Metrics/MethodLength:
+  Exclude:
+    - 'lib/scim/kit/cli/app.rb'
+
 Metrics/ModuleLength:
   Exclude:
     - 'spec/**/*.rb'
CHANGELOG.md
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - Add a `scim-kit` CLI with `discover`, `list`, and `get` commands for
   reading a remote SCIM server's configuration and resources.
+- Add a `--validate` flag to `discover`, `list`, and `get` for checking
+  responses against a JSON Schema (built-in RFC 7643 schemas for
+  `discover`; derived from the target server's own `/Schemas` for
+  `list`/`get`).
 
 ## [0.8.0] - 2026-03-31
 ### Changed
Gemfile.lock
@@ -3,6 +3,7 @@ PATH
   specs:
     scim-kit (0.8.0)
       activemodel (>= 6.1)
+      json_schemer (~> 2.0)
       net-hippie (~> 1.0)
       parslet (~> 2.0)
       thor (~> 1.0)
@@ -53,6 +54,7 @@ GEM
     drb (2.2.1)
     erubi (1.13.0)
     ffaker (2.23.0)
+    hana (1.3.7)
     hashdiff (1.1.2)
     i18n (1.14.6)
       concurrent-ruby (~> 1.0)
@@ -60,6 +62,11 @@ GEM
       actionview (>= 5.0.0)
       activesupport (>= 5.0.0)
     json (2.9.0)
+    json_schemer (2.5.0)
+      bigdecimal
+      hana (~> 1.3)
+      regexp_parser (~> 2.0)
+      simpleidn (~> 0.2)
     language_server-protocol (3.17.0.3)
     logger (1.6.2)
     loofah (2.23.1)
@@ -141,6 +148,7 @@ GEM
       rubocop (~> 1.61)
     ruby-progressbar (1.13.0)
     securerandom (0.4.0)
+    simpleidn (0.2.3)
     thor (1.5.0)
     tilt (2.4.0)
     tilt-jbuilder (0.7.1)
scim-kit.gemspec
@@ -31,6 +31,7 @@ Gem::Specification.new do |spec|
   spec.metadata['yard.run'] = 'yri'
 
   spec.add_dependency 'activemodel', '>= 6.1'
+  spec.add_dependency 'json_schemer', '~> 2.0'
   spec.add_dependency 'net-hippie', '~> 1.0'
   spec.add_dependency 'parslet', '~> 2.0'
   spec.add_dependency 'thor', '~> 1.0'