vetra 0.1.0.pre.alpha.4 → 0.1.0.pre.alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b86b8c6c0b5d01eaa87aef9034e0237fbb29bf0e154fe44fecd5adb3b3101890
4
- data.tar.gz: '0880a89f19a9ad570a34b38b2434de8e2c979b1607bc5709c685be932faeac63'
3
+ metadata.gz: 1d2dd87928b914db9b1144b0f5e17c356c7ad3aaaf473fe3c58f08fd37400527
4
+ data.tar.gz: a6236f38b853eb0c37b2a99f6087cb6150655c4f0f60a1ab74c94e4cb17b7727
5
5
  SHA512:
6
- metadata.gz: a26b289cefeed49b0aadd23d0016e7b51adff2fbfad8c7d3d818ecf3a3bb8d7e3e9d0196e60d4b4aa2bae8d2df48784b5b4bce6aaf7fa521a1ac4aa038f7337d
7
- data.tar.gz: 280011e13348cf28bfdaf77aff685b9ad004ad3571a379ab9a804bb5bb3d6bce104506148d5fcceccdd9a216243f498ff05ee72fe1512c769176a3f22e43bfbb
6
+ metadata.gz: c2006e7dbef28ac026b4cfda4babbe68809b96700a66b4e106d5e8ede0bd18c8c580d1a38f94f1dea737bd1bf0125906608ac4c91900f9436b4e0c4b15eff979
7
+ data.tar.gz: 39938ef9de395d7bc988ecbe3a916aabd86e8da5f49bb662edf06c53c20fdbaaf03d789aeed4c9580ad9f62354dee4dea911619cf0816f298f320ca51b5566c6
data/lib/vetra/cli.rb CHANGED
@@ -6,20 +6,35 @@ module Vetra
6
6
  def self.run(arguments, out: $stdout, err: $stderr)
7
7
  options = { root: Dir.pwd, sources: false, format: nil, fail_on_breaking: false }
8
8
  parser = OptionParser.new do |flags|
9
- flags.banner = "Uso: vetra graph:build|graph:check|graph:link [--root diretório]\n vetra graph:validate [arquivo] [--root diretório] [--sources]\n vetra graph:diff ANTERIOR POSTERIOR [--root diretório] [--format text|json] [--fail-on-breaking]"
9
+ flags.banner = "Uso: vetra graph:build|graph:check|graph:link [--root diretório]\n vetra graph:validate [arquivo] [--root diretório] [--sources]\n vetra graph:diff ANTERIOR POSTERIOR [--root diretório] [--format text|json] [--fail-on-breaking]\n vetra db:authz:sql [--root diretório] [--sql-schema nome] [--owner-role nome] [--app-role nome]"
10
10
  flags.on('--root PATH', 'Raiz da aplicação') { |value| options[:root] = value }
11
11
  flags.on('--sources', 'Também verificar manifesto em graph:validate') { options[:sources] = true }
12
12
  flags.on('--format FORMAT', %w[text json], 'Formato de graph:diff: text ou json') { |value| options[:format] = value }
13
13
  flags.on('--fail-on-breaking', 'Sair com código 3 se o diff apontar quebra potencial') { options[:fail_on_breaking] = true }
14
+ flags.on('--sql-schema NAME', 'Schema novo do SQL de revisão de autorização') { |value| options[:schema] = value }
15
+ flags.on('--owner-role NAME', 'Papel proprietário já existente') { |value| options[:owner_role] = value }
16
+ flags.on('--app-role NAME', 'Papel da aplicação já existente') { |value| options[:app_role] = value }
14
17
  flags.on('-h', '--help', 'Mostrar ajuda') { out.puts(flags); return 0 }
15
18
  flags.on('--version', 'Mostrar versão do protótipo') { out.puts(VERSION); return 0 }
16
19
  end
17
20
  args = parser.parse(arguments.dup)
18
21
  command = args.shift
19
- unless %w[graph:build graph:check graph:validate graph:diff graph:link].include?(command)
22
+ unless %w[graph:build graph:check graph:validate graph:diff graph:link db:authz:sql].include?(command)
20
23
  err.puts(parser)
21
24
  return 2
22
25
  end
26
+ sql_options = options.slice(:schema, :owner_role, :app_role)
27
+ if command == 'db:authz:sql'
28
+ unless args.empty? && !options[:sources] && !options[:format] && !options[:fail_on_breaking]
29
+ raise OptionParser::InvalidArgument, 'db:authz:sql aceita somente --root e opções de schema/papéis'
30
+ end
31
+ result = Graph::Compiler.new(root: options[:root]).read_artifact
32
+ generated = SQL::Authorization.new(result.document, **sql_options).generate
33
+ generated.warnings.each { |diagnostic| err.puts(diagnostic) }
34
+ out.write(generated.sql)
35
+ return 0
36
+ end
37
+ raise OptionParser::InvalidArgument, 'opções de SQL exigem db:authz:sql' unless sql_options.empty?
23
38
  if command == 'graph:diff'
24
39
  raise OptionParser::InvalidArgument, 'graph:diff exige duas entradas e não aceita --sources' unless args.size == 2 && !options[:sources]
25
40
  source = Graph::DiffSource.new(root: options[:root])
@@ -65,10 +65,10 @@ module Vetra
65
65
  root.join('.vetra/graph.json')
66
66
  end
67
67
 
68
- def link
68
+ def read_artifact
69
69
  bytes = artifact_bytes
70
70
  document = Canonical.parse(bytes)
71
- Validator.new.validate!(document)
71
+ warnings = Validator.new.validate!(document)
72
72
  unless bytes == "#{Canonical.dump(document)}\n"
73
73
  raise Error.single('graph_not_canonical', 'O artefato deve usar JSON canônico seguido de LF; execute graph:build no pipeline.')
74
74
  end
@@ -78,7 +78,16 @@ module Vetra
78
78
  end
79
79
  manifest = Manifest.new(root)
80
80
  manifest.verify!(document)
81
- Data.freeze(document)
81
+ unless artifact_bytes == bytes
82
+ raise Error.single('graph_changed', 'O artefato mudou durante a leitura; use um pacote imutável.')
83
+ end
84
+ Data.freeze(Result.new(document: document, warnings: warnings, implementation_ids: []))
85
+ end
86
+
87
+ def link
88
+ result = read_artifact
89
+ document, bytes = result.document, result.bytes
90
+ manifest = Manifest.new(root)
82
91
 
83
92
  # Registration is needed to recreate Ruby closures in this process. The
84
93
  # normalized declaration is an audit only: it never replaces the artifact.
@@ -0,0 +1,192 @@
1
+ require 'digest'
2
+
3
+ module Vetra
4
+ module SQL
5
+ class Authorization
6
+ Result = Struct.new(:sql, :tables, :warnings, keyword_init: true)
7
+
8
+ def initialize(document, schema: 'vetra', owner_role: 'vetra_owner', app_role: 'vetra_app')
9
+ @document = Graph::Data.copy(document)
10
+ @schema, @owner, @app = schema, owner_role, app_role
11
+ end
12
+
13
+ def generate
14
+ warnings = Graph::Validator.new.validate!(@document)
15
+ [@schema, @owner, @app].each { |name| identifier!(name) }
16
+ if @owner == @app || @schema.start_with?('pg_') || @schema == 'information_schema'
17
+ raise Graph::Error.single('authz_roles', 'Use papéis distintos e um schema de infraestrutura fora dos schemas reservados.')
18
+ end
19
+ tenant = @document.dig('app', 'tenant', 'entity', 'id')
20
+ raise Graph::Error.single('authz_tenant', 'O bootstrap de revisão exige tenant explícito; aplicações globais ainda não são suportadas.', pointer: '/app/tenant') unless tenant
21
+ entities = @document['nodes'].select { |node| node['kind'] == 'entity' }.to_h { |node| [node['id'], node] }
22
+ @root = entities.fetch(tenant)
23
+ ids = [tenant] + @document['nodes'].select { |node| node['kind'] == 'policy' }.flat_map { |node| node['reads'].map { |ref| ref['id'] } }
24
+ tables = ids.uniq.sort.map do |id|
25
+ entity = entities.fetch(id)
26
+ column = if id == tenant
27
+ 'id'
28
+ elsif entity.dig('tenant', 'entity', 'id') == tenant
29
+ entity.dig('tenant', 'field')
30
+ end
31
+ unless column
32
+ raise Graph::Error.single('authz_scope', "#{id} é uma dependência global de autorização; falta definir sua chave de revisão.", source: entity['source'])
33
+ end
34
+ storage = entity['storage']
35
+ [storage['schema'], storage['table'], column].each { |name| identifier!(name) }
36
+ { 'entity' => id, 'schema' => storage['schema'], 'table' => storage['table'], 'tenant_column' => column }
37
+ end
38
+ if @root.dig('storage', 'primary_key') != ['id'] || tables.any? { |table| table['schema'] == @schema } || tables.map { |table| table.values_at('schema', 'table') }.uniq.size != tables.size
39
+ raise Graph::Error.single('authz_storage', 'Use raiz com PK id, tabelas físicas distintas e schema de infraestrutura separado das tabelas da aplicação.')
40
+ end
41
+ @tables = tables.sort_by { |table| table.values_at('schema', 'table') }
42
+ sql = [header, 'BEGIN;', "SET LOCAL lock_timeout = '3s';", 'SET LOCAL search_path = pg_catalog, pg_temp;', role_guards,
43
+ "LOCK TABLE #{@tables.map { |table| "ONLY #{relation(table)}" }.join(', ')} IN SHARE ROW EXCLUSIVE MODE;",
44
+ storage_guards, infrastructure, *@tables.map { |table| triggers(table) }, privilege_guards,
45
+ "-- Varredura da raiz do tenant para preencher revisões iniciais.",
46
+ "INSERT INTO #{revisions} (tenant_id, revision) SELECT #{quote('id')}::text, 0 FROM #{root_relation};", 'COMMIT;', ''].join("\n")
47
+ Graph::Data.freeze(Result.new(sql: sql, tables: @tables, warnings: warnings))
48
+ end
49
+
50
+ private
51
+
52
+ def identifier!(name)
53
+ unless name.is_a?(String) && name.match?(/\A[a-z][a-z0-9_]*\z/) && name.bytesize <= 63
54
+ raise Graph::Error.single('authz_identifier', 'Identificadores SQL exigem letras minúsculas, números ou sublinhado, começam por letra e têm até 63 bytes.')
55
+ end
56
+ end
57
+
58
+ def quote(name)
59
+ %Q{"#{name}"}
60
+ end
61
+
62
+ def literal(value)
63
+ "'#{value.gsub("'", "''")}'"
64
+ end
65
+
66
+ def relation(table)
67
+ "#{quote(table['schema'])}.#{quote(table['table'])}"
68
+ end
69
+
70
+ def root_relation
71
+ relation(@root['storage'])
72
+ end
73
+
74
+ def revisions
75
+ "#{quote(@schema)}.#{quote('authz_revisions')}"
76
+ end
77
+
78
+ def header
79
+ (["-- Vetra: bootstrap de revisão de autorização; SQL para revisão, sem aplicação automática.",
80
+ "-- Grafo #{@document['digest']}; aplicação #{JSON.generate(@document.dig('app', 'name'))}.",
81
+ '-- Requer PostgreSQL 17+, tabelas existentes, papéis existentes e schema de infraestrutura novo.',
82
+ '-- Instalação: bloqueia escritas com SHARE ROW EXCLUSIVE; lock_timeout = 3s; lê os IDs da raiz.'] +
83
+ @tables.map { |table| "-- #{table['entity']}: #{relation(table)}; tenant pela coluna #{quote(table['tenant_column'])}." }).join("\n")
84
+ end
85
+
86
+ def role_guards
87
+ <<~SQL
88
+ DO $vetra_roles$
89
+ BEGIN
90
+ IF current_setting('server_version_num')::integer < 170000 THEN
91
+ RAISE EXCEPTION 'Vetra exige PostgreSQL 17 ou superior';
92
+ END IF;
93
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = #{literal(@owner)} AND NOT rolcanlogin AND NOT rolsuper AND NOT rolbypassrls AND NOT rolcreaterole) THEN
94
+ RAISE EXCEPTION 'Proprietário deve existir, sem LOGIN, SUPERUSER, BYPASSRLS ou CREATEROLE';
95
+ END IF;
96
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = #{literal(@app)} AND NOT rolsuper AND NOT rolbypassrls AND NOT rolcreaterole) THEN
97
+ RAISE EXCEPTION 'Aplicação deve existir, sem SUPERUSER, BYPASSRLS ou CREATEROLE';
98
+ END IF;
99
+ IF pg_has_role(#{literal(@app)}, #{literal(@owner)}, 'MEMBER') THEN
100
+ RAISE EXCEPTION 'Aplicação não pode pertencer ao papel proprietário';
101
+ END IF;
102
+ END $vetra_roles$;
103
+ SQL
104
+ end
105
+
106
+ def storage_guards
107
+ checks = @tables.map do |table|
108
+ path = literal(relation(table))
109
+ column = literal(table['tenant_column'])
110
+ <<~SQL
111
+ IF NOT EXISTS (SELECT 1 FROM pg_class WHERE oid = #{path}::regclass AND relkind = 'r' AND NOT relispartition)
112
+ OR EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = #{path}::regclass OR inhparent = #{path}::regclass) THEN
113
+ RAISE EXCEPTION 'Tabelas particionadas ou herdadas não são suportadas: %', #{path};
114
+ END IF;
115
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_attribute root ON root.attrelid = #{literal(root_relation)}::regclass AND root.attname = 'id'
116
+ WHERE a.attrelid = #{path}::regclass AND a.attname = #{column} AND NOT a.attisdropped AND NOT root.attisdropped
117
+ AND a.attnotnull AND root.attnotnull AND a.atttypid = root.atttypid AND a.atttypmod = root.atttypmod
118
+ AND root.atttypid IN ('uuid'::regtype, 'text'::regtype, 'varchar'::regtype, 'bpchar'::regtype, 'int2'::regtype, 'int4'::regtype, 'int8'::regtype)) THEN
119
+ RAISE EXCEPTION 'Coluna de tenant ausente, nula ou de tipo incompatível: %.%', #{path}, #{column};
120
+ END IF;
121
+ IF EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = #{path}::regclass
122
+ AND (pg_has_role(#{literal(@app)}, c.relowner, 'MEMBER') OR pg_has_role(#{literal(@app)}, n.nspowner, 'MEMBER'))) THEN
123
+ RAISE EXCEPTION 'Aplicação não pode possuir tabela protegida ou seu schema: %', #{path};
124
+ END IF;
125
+ SQL
126
+ end
127
+ <<~SQL
128
+ DO $vetra_tables$
129
+ BEGIN
130
+ IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attname = 'id'
131
+ WHERE i.indrelid = #{literal(root_relation)}::regclass AND i.indisprimary AND i.indnatts = 1 AND i.indkey[0] = a.attnum) THEN
132
+ RAISE EXCEPTION 'Raiz do tenant deve ter chave primária simples em id';
133
+ END IF;
134
+ #{checks.join}
135
+ END $vetra_tables$;
136
+ SQL
137
+ end
138
+
139
+ def infrastructure
140
+ <<~SQL
141
+ CREATE SCHEMA #{quote(@schema)} AUTHORIZATION #{quote(@owner)};
142
+ CREATE TABLE #{revisions} (tenant_id text PRIMARY KEY, revision bigint NOT NULL CHECK (revision >= 0));
143
+ ALTER TABLE #{revisions} OWNER TO #{quote(@owner)};
144
+ REVOKE ALL ON #{revisions} FROM PUBLIC, #{quote(@app)};
145
+ GRANT USAGE ON SCHEMA #{quote(@schema)} TO #{quote(@app)};
146
+ GRANT SELECT ON #{revisions} TO #{quote(@app)};
147
+ SQL
148
+ end
149
+
150
+ def triggers(table)
151
+ suffix = Digest::SHA256.hexdigest([@document.dig('app', 'name'), table['entity']].join("\0"))[0, 24]
152
+ column = quote(table['tenant_column'])
153
+ {
154
+ 'insert' => ['NEW TABLE AS vt_new', "SELECT DISTINCT #{column}::text AS tenant_id FROM vt_new ORDER BY tenant_id"],
155
+ 'delete' => ['OLD TABLE AS vt_old', "SELECT DISTINCT #{column}::text AS tenant_id FROM vt_old ORDER BY tenant_id"],
156
+ 'update' => ['OLD TABLE AS vt_old NEW TABLE AS vt_new', "SELECT #{column}::text AS tenant_id FROM vt_old UNION SELECT #{column}::text AS tenant_id FROM vt_new ORDER BY tenant_id"]
157
+ }.map do |event, (references, tenants)|
158
+ name = quote("vt_authz_#{suffix}_#{event}")
159
+ function = "#{quote(@schema)}.#{name}"
160
+ <<~SQL
161
+ CREATE FUNCTION #{function}() RETURNS trigger LANGUAGE plpgsql
162
+ SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $vetra_authz$
163
+ DECLARE affected_tenant text;
164
+ BEGIN
165
+ FOR affected_tenant IN #{tenants} LOOP
166
+ INSERT INTO #{revisions} AS stored (tenant_id, revision) VALUES (affected_tenant, 1)
167
+ ON CONFLICT (tenant_id) DO UPDATE SET revision = stored.revision + 1;
168
+ PERFORM pg_notify('vetra_authz', affected_tenant);
169
+ END LOOP;
170
+ RETURN NULL;
171
+ END $vetra_authz$;
172
+ ALTER FUNCTION #{function}() OWNER TO #{quote(@owner)};
173
+ REVOKE ALL ON FUNCTION #{function}() FROM PUBLIC, #{quote(@app)};
174
+ CREATE TRIGGER #{name} AFTER #{event.upcase} ON #{relation(table)}
175
+ REFERENCING #{references} FOR EACH STATEMENT EXECUTE FUNCTION #{function}();
176
+ SQL
177
+ end.join + "REVOKE TRUNCATE, TRIGGER ON #{relation(table)} FROM PUBLIC, #{quote(@app)};\n"
178
+ end
179
+
180
+ def privilege_guards
181
+ checks = @tables.map do |table|
182
+ <<~SQL
183
+ IF has_table_privilege(#{literal(@app)}, #{literal(relation(table))}, 'TRUNCATE,TRIGGER') THEN
184
+ RAISE EXCEPTION 'Privilégios herdados de TRUNCATE/TRIGGER ainda permitem alterar a invalidação';
185
+ END IF;
186
+ SQL
187
+ end
188
+ "DO $vetra_privileges$\nBEGIN\n#{checks.join}END $vetra_privileges$;\n"
189
+ end
190
+ end
191
+ end
192
+ end
data/lib/vetra/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Vetra
2
- VERSION = "0.1.0-alpha.4"
2
+ VERSION = "0.1.0-alpha.5"
3
3
  end
data/lib/vetra.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "vetra/graph/linker"
18
18
  require_relative "vetra/graph/compiler"
19
19
  require_relative "vetra/graph/diff_source"
20
20
  require_relative "vetra/graph/diff"
21
+ require_relative "vetra/sql/authorization"
21
22
 
22
23
  module Vetra
23
24
  def self.app(name, &block)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vetra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.4
4
+ version: 0.1.0.pre.alpha.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denilson Silva
@@ -85,6 +85,11 @@ dependencies:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
87
  version: '13.2'
88
+ description: Protótipo da fase 0 do Vetra, um framework web para Ruby 4 e PostgreSQL
89
+ que descreve a aplicação como um grafo verificável. Compila o DSL de entidades,
90
+ comandos, consultas, permissões e eventos numa IR versionada, valida o artefato,
91
+ compara versões e vincula os blocos Ruby. Ainda não executa operações nem serve
92
+ HTTP.
88
93
  executables:
89
94
  - vetra
90
95
  extensions: []
@@ -117,10 +122,16 @@ files:
117
122
  - lib/vetra/graph/registry.rb
118
123
  - lib/vetra/graph/schema.rb
119
124
  - lib/vetra/graph/validator.rb
125
+ - lib/vetra/sql/authorization.rb
120
126
  - lib/vetra/version.rb
127
+ homepage: https://github.com/vetra-framework/vetra
121
128
  licenses:
122
129
  - MIT
123
- metadata: {}
130
+ metadata:
131
+ source_code_uri: https://github.com/vetra-framework/vetra
132
+ documentation_uri: https://github.com/vetra-framework/vetra/blob/main/docs/fase-0.md
133
+ bug_tracker_uri: https://github.com/vetra-framework/vetra/issues
134
+ rubygems_mfa_required: 'true'
124
135
  rdoc_options: []
125
136
  require_paths:
126
137
  - lib