Skip to content

Commit

Permalink
App registry step (#24)
Browse files Browse the repository at this point in the history
* Domain Ownership Service implemented with AppRegistry

* Devcontainer includes mock App Registry
  • Loading branch information
suprjinx authored Sep 13, 2024
1 parent bebd047 commit 8d7236e
Show file tree
Hide file tree
Showing 29 changed files with 325 additions and 153 deletions.
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
2 changes: 1 addition & 1 deletion app/interactors/authenticate_identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class AuthenticateIdentity
end

def call
if identity = Services::AuthService.new.authenticate!(context.token)
if identity = Services::AuthService.authenticate!(context.token)
context.identity = identity
else
context.fail!(message: "Invalid token")
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|
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
2 changes: 1 addition & 1 deletion app/interactors/obtain_cert.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class ObtainCert
include FailOnError

def call
if cert = Services::CertificateService.new.issue_cert(context.request)
if cert = Services::CertificateService.issue_cert(context.request)
context.cert = cert
else
context.fail!(message: "Failed to issue certificate")
Expand Down
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.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
53 changes: 53 additions & 0 deletions app/lib/services/app_registry_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
module Services
class AppRegistryService
class << self
def get_domain_info(fqdn)
rslt = client.get("/api/v1beta1/domain-names/#{fqdn}").body
convert(rslt)
rescue Faraday::ResourceNotFound => e
nil
end

private

def 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 convert(domain_info)
if !domain_info || domain_info["isDeleted"]
return nil
end

OpenStruct.new(
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
end
38 changes: 16 additions & 22 deletions app/lib/services/auth_service.rb
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
module Services
class AuthService
def initialize
@domain_ownership_service = DomainOwnershipService.new
end

def authenticate!(token)
identity = decode(token)
raise AuthError unless identity
# TODO verify identity with authority?
identity
end

def authorize!(identity, cert_issue_req)
@domain_ownership_service.authorize!(identity, cert_issue_req)
end
class << self
def authenticate!(token)
identity = decode(token)
raise AuthError unless identity
# TODO verify identity with authority?
identity
end

private
private

def decode(token)
# Decode a JWT access token using the configured base.
body = JWT.decode(token, Rails.application.config.astral[:jwt_signing_key])[0]
Identity.new(body)
rescue => e
Rails.logger.warn "Unable to decode token: #{e}"
nil
def decode(token)
# Decode a JWT access token using the configured base.
body = JWT.decode(token, Rails.configuration.astral[:jwt_signing_key])[0]
Identity.new(body)
rescue => e
Rails.logger.warn "Unable to decode token: #{e}"
nil
end
end
end
end
16 changes: 10 additions & 6 deletions app/lib/services/certificate_service.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
module Services
class CertificateService
def initialize
# TODO this should select an implementation service based on config
@impl = VaultService.new
end
class << self
def issue_cert(cert_issue_request)
impl.issue_cert(cert_issue_request)
end

private

def issue_cert(cert_issue_request)
@impl.issue_cert(cert_issue_request)
def impl
# TODO this should select an implementation service based on config
VaultService
end
end
end
end
18 changes: 10 additions & 8 deletions app/lib/services/domain_ownership_service.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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?))
class << self
def get_domain_info(fqdn)
impl.get_domain_info(fqdn)
end

private

def impl
# TODO this should select an implementation service based on config
AppRegistryService
end
nil
end
end
end
28 changes: 16 additions & 12 deletions app/lib/services/vault_service.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
module Services
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]
)
end
class << self
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.configuration.astral[:vault_cert_path], opts)
OpenStruct.new tls_cert.data
end

private

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)
OpenStruct.new tls_cert.data
def client
# TODO create a new token for use in the session
Vault::Client.new(
address: Rails.configuration.astral[:vault_addr],
token: Rails.configuration.astral[:vault_token]
)
end
end
end
end
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
Loading

0 comments on commit 8d7236e

Please sign in to comment.