Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App registry step #24

Merged
merged 15 commits into from
Sep 13, 2024
Merged
18 changes: 18 additions & 0 deletions .devcontainer/app_reg_db.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"domain-names": [
{
"id": "example.com",
"fullyQualifiedDomainName": "example.com",
"ownerDelegatedRequestsToTeam": true,
"autoApprovedGroups": "group1",
"autoApprovedServiceAccounts": "[email protected]"
},
{
"id": "example2.com",
"fullyQualifiedDomainName": "example2.com",
"ownerDelegatedRequestsToTeam": true,
"autoApprovedGroups": "group1",
"autoApprovedServiceAccounts": "[email protected]"
}
]
}
3 changes: 3 additions & 0 deletions .devcontainer/app_reg_routes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"/api/v1beta1/*": "/$1"
}
16 changes: 16 additions & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:
VAULT_ADDR: http://10.1.10.100:8200
VAULT_TOKEN: root_token
JWT_SIGNING_KEY: jwt_secret
APP_REGISTRY_ADDR: http://10.1.10.150:8800
APP_REGISTRY_TOKEN: app_reg_token

vault:
image: hashicorp/vault:latest
Expand All @@ -32,6 +34,20 @@ services:
astral:
ipv4_address: "10.1.10.100"

app_registry:
image: node:latest
restart: unless-stopped
ports:
- 8800:8800
volumes:
- .:/data
networks:
astral:
ipv4_address: "10.1.10.150"
command: >
sh -c "npm install -g [email protected] &&
json-server /data/app_reg_db.json --routes /data/app_reg_routes.json --port 8800 --host 0.0.0.0"

networks:
astral:
ipam:
Expand Down
9 changes: 6 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ gem "rails", "~> 7.2.1"
gem "sqlite3", ">= 1.4"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"

# Use Redis adapter to run Action Cable in production
# gem "redis", ">= 4.0.1"

Expand Down Expand Up @@ -38,9 +37,13 @@ gem "vault"
# Use the jwt gem to decode access tokens
gem "jwt"

# Use the jbuilder gem
# Use the jbuilder gem to render JSON views
gem "jbuilder"

# Use the faraday gem for http client operations
gem "faraday"
gem "faraday-retry"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri mswin ], require: "debug/prelude"
Expand Down
12 changes: 12 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ GEM
reline (>= 0.3.8)
drb (2.2.1)
erubi (1.13.0)
faraday (2.11.0)
faraday-net_http (>= 2.0, < 3.4)
logger
faraday-net_http (3.3.0)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.5)
Expand Down Expand Up @@ -120,6 +127,8 @@ GEM
mini_mime (1.1.5)
minitest (5.25.1)
msgpack (1.7.2)
net-http (0.4.1)
uri
net-imap (0.4.14)
date
net-protocol
Expand Down Expand Up @@ -245,6 +254,7 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (0.13.1)
useragent (0.16.10)
vault (0.18.2)
aws-sigv4
Expand Down Expand Up @@ -274,6 +284,8 @@ DEPENDENCIES
bootsnap
brakeman
debug
faraday
faraday-retry
interactor (~> 3.0)
jbuilder
jwt
Expand Down
9 changes: 8 additions & 1 deletion app/interactors/authorize_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ class AuthorizeRequest
include FailOnError

def call
Services::DomainOwnershipService.new.authorize!(context.identity, context.request)
context.request.fqdns.each do |fqdn|
suprjinx marked this conversation as resolved.
Show resolved Hide resolved
domain = Domain.where(fqdn: fqdn).first
raise AuthError.new("Common or alt name not recognized") unless domain
raise AuthError.new("No subject or group authorization") unless
domain.users_array.include?(context.identity.subject) ||
(domain.group_delegation? && (domain.groups_array & context.identity.groups).any?)
end
nil
end
end
3 changes: 3 additions & 0 deletions app/interactors/fail_on_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ module FailOnError
included do
around do |interactor|
interactor.call
rescue Interactor::Failure => e
raise e
rescue => e
Rails.logger.error("Error in #{self.class.name}: #{e.class.name} - #{e.message}")
context.fail!(error: e)
end
end
Expand Down
3 changes: 2 additions & 1 deletion app/interactors/issue_cert.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class IssueCert
include Interactor::Organizer
include FailOnError

organize AuthorizeRequest, ObtainCert, Log
organize RefreshDomain, AuthorizeRequest, ObtainCert, Log
end
20 changes: 20 additions & 0 deletions app/interactors/refresh_domain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class RefreshDomain
include Interactor

def call
domain_info = Services::DomainOwnershipService.new.get_domain_info(context.request.common_name)
domain_record = Domain.find_or_create_by!(fqdn: context.request.common_name)
if !domain_info
domain_record.destroy!
return
end

domain_record.update!(
group_delegation: domain_info.group_delegation,
groups: domain_info.groups,
users: domain_info.users
)
rescue => e
Rails.logger.warn("Continuing after error in #{self.class.name}: #{e.class.name}: #{e.message}")
end
end
2 changes: 1 addition & 1 deletion app/lib/services/auth_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def authorize!(identity, cert_issue_req)

def decode(token)
# Decode a JWT access token using the configured base.
body = JWT.decode(token, Rails.application.config.astral[:jwt_signing_key])[0]
body = JWT.decode(token, Rails.configuration.astral[:jwt_signing_key])[0]
dave-gantenbein marked this conversation as resolved.
Show resolved Hide resolved
Identity.new(body)
rescue => e
Rails.logger.warn "Unable to decode token: #{e}"
Expand Down
53 changes: 46 additions & 7 deletions app/lib/services/domain_ownership_service.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
module Services
class DomainOwnershipService
def authorize!(identity, cert_req)
cert_req.fqdns.each do |fqdn|
domain = Domain.where(fqdn: fqdn).first
raise AuthError unless domain.present? &&
(domain.owner == identity.subject ||
(domain.group_delegation &&
(domain.groups & identity.groups).any?))
attr_reader :client

def initialize
@client = Faraday.new(ssl: ssl_opts, url: Rails.configuration.astral[:app_registry_addr]) do |faraday|
faraday.request :authorization, "Bearer", -> { Rails.configuration.astral[:app_registry_token] }
faraday.request :retry, retry_opts
faraday.response :json
faraday.response :raise_error, include_request: true
end
end

def get_domain_info(fqdn)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we follow a pattern of making these class methods instead of instance methods? The calling code looked weird to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the advantage of using instance is you get the initialize method to do some setup first -- but it's definitely possible to manage this with class or module (eg, singleton instance on class to do the work)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems more Railsy to do it the singleton way, ie User.first etc etc

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

converted these to class-method interfaces

rslt = client.get("/api/v1beta1/domain-names/#{fqdn}").body
convert(rslt)
rescue Faraday::ResourceNotFound => e
nil
end

private

def convert(domain_info)
if !domain_info || domain_info["isDeleted"]
return nil
end

OpenStruct.new(
suprjinx marked this conversation as resolved.
Show resolved Hide resolved
fqdn: domain_info["fullyQualifiedDomainName"],
group_delegation: domain_info["ownerDelegatedRequestsToTeam"],
groups: domain_info["autoApprovedGroups"],
users: domain_info["autoApprovedServiceAccounts"]
)
end

def ssl_opts
{
ca_file: Rails.configuration.astral[:app_registry_ca_file],
client_cert: Rails.configuration.astral[:app_registry_client_cert],
client_key: Rails.configuration.astral[:app_registry_client_key]
}
end

def retry_opts
{
max: 3,
interval: 0.05,
interval_randomness: 0.5,
backoff_factor: 2
}
end
end
end
6 changes: 3 additions & 3 deletions app/lib/services/vault_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ class VaultService
def initialize
# TODO create a new token for use in the session
@client = Vault::Client.new(
address: Rails.application.config.astral[:vault_addr],
token: Rails.application.config.astral[:vault_token]
address: Rails.configuration.astral[:vault_addr],
token: Rails.configuration.astral[:vault_token]
)
end

def issue_cert(cert_issue_request)
opts = cert_issue_request.attributes
# Generate the TLS certificate using the intermediate CA
tls_cert = @client.logical.write(Rails.application.config.astral[:vault_cert_path], opts)
tls_cert = @client.logical.write(Rails.configuration.astral[:vault_cert_path], opts)
OpenStruct.new tls_cert.data
end
end
Expand Down
11 changes: 6 additions & 5 deletions app/models/domain.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
class Domain < ApplicationRecord
serialize :groups, coder: YAML, type: Array
before_save :clean_groups
validates :fqdn, presence: true

validates :fqdn, :owner, presence: true
def groups_array
(groups || "").split(",").sort.uniq
end

def clean_groups
self.groups = groups.sort.uniq
def users_array
(users || "").split(",").sort.uniq
end
end
5 changes: 5 additions & 0 deletions config/astral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ shared:
vault_cert_path: "pki_int/issue/learn"
jwt_signing_key: <%= ENV["JWT_SIGNING_KEY"] %>
cert_ttl: <%= ENV["CERT_TTL"] %>
app_registry_addr: <%= ENV["APP_REGISTRY_ADDR"] %>
app_registry_token: <%= ENV["APP_REGISTRY_TOKEN"] %>
app_registry_ca_file: <%= ENV["APP_REGISTRY_CA_FILE"] %>
app_registry_client_cert: <%= ENV["APP_REGISTRY_CLIENT_CERT"] %>
app_registry_client_key: <%= ENV["APP_REGISTRY_CLIENT_KEY"] %>

test:
cert_ttl: <%= 24.hours.in_seconds %>
Expand Down
5 changes: 2 additions & 3 deletions db/migrate/20240904175652_create_domains.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
class CreateDomains < ActiveRecord::Migration[7.2]
def change
create_table :domains do |t|
t.string :fqdn, null: false
t.string :owner, null: false
t.string :fqdn, null: false, index: { unique: true }
t.text :users
t.text :groups
t.boolean :group_delegation, default: false
t.timestamps
t.index :fqdn, unique: true
end
end
end
2 changes: 1 addition & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@

# this seed is for development only
if Rails.env.development?
Domain.first_or_create!(fqdn: "example.com", owner: "[email protected]")
Domain.find_or_create_by!(fqdn: "example.com", users: "[email protected]")
end
12 changes: 5 additions & 7 deletions test/fixtures/domains.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
owner_match:
fqdn: example.com
owner: [email protected]
users: john.doe@example.com,some.other@example.com
group_delegation: false

group_match:
fqdn: example2.com
owner: [email protected]
users: [email protected]
group_delegation: true
groups:
- "group1"
groups: group1,group2

no_match:
fqdn: example3.com
owner: [email protected]
users: some.other@example2.com,yet.another@example2.com
group_delegation: true
groups:
- "group3"
groups: group3,group4
Loading