cnfs-iam 0.0.1.alpha

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.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +75 -0
  4. data/Rakefile +24 -0
  5. data/app/controllers/concerns/is_tenant_scoped.rb +21 -0
  6. data/app/controllers/credentials_controller.rb +23 -0
  7. data/app/controllers/groups_controller.rb +4 -0
  8. data/app/controllers/iam/application_controller.rb +6 -0
  9. data/app/controllers/iam/confirmations_controller.rb +51 -0
  10. data/app/controllers/iam/passwords_controller.rb +56 -0
  11. data/app/controllers/iam/sessions_controller.rb +21 -0
  12. data/app/controllers/policies_controller.rb +4 -0
  13. data/app/controllers/public_keys_controller.rb +4 -0
  14. data/app/controllers/roots/sessions_controller.rb +16 -0
  15. data/app/controllers/roots_controller.rb +4 -0
  16. data/app/controllers/users/confirmations_controller.rb +21 -0
  17. data/app/controllers/users/passwords_controller.rb +25 -0
  18. data/app/controllers/users/sessions_controller.rb +19 -0
  19. data/app/controllers/users_controller.rb +17 -0
  20. data/app/mailers/account_mailer.rb +74 -0
  21. data/app/models/action.rb +6 -0
  22. data/app/models/credential.rb +47 -0
  23. data/app/models/group.rb +15 -0
  24. data/app/models/group_policy_join.rb +25 -0
  25. data/app/models/iam/application_record.rb +7 -0
  26. data/app/models/policy.rb +10 -0
  27. data/app/models/policy_action.rb +6 -0
  28. data/app/models/public_key.rb +17 -0
  29. data/app/models/role.rb +11 -0
  30. data/app/models/role_policy_join.rb +6 -0
  31. data/app/models/root.rb +26 -0
  32. data/app/models/root_credential.rb +7 -0
  33. data/app/models/tenant.rb +68 -0
  34. data/app/models/user.rb +69 -0
  35. data/app/models/user_credential.rb +8 -0
  36. data/app/models/user_group.rb +25 -0
  37. data/app/models/user_policy_join.rb +21 -0
  38. data/app/models/user_role.rb +6 -0
  39. data/app/operations/blackcomb_user_create.rb +49 -0
  40. data/app/operations/user_create.rb +53 -0
  41. data/app/policies/action_policy.rb +3 -0
  42. data/app/policies/credential_policy.rb +3 -0
  43. data/app/policies/group_policy.rb +3 -0
  44. data/app/policies/iam/application_policy.rb +6 -0
  45. data/app/policies/policy_policy.rb +3 -0
  46. data/app/policies/public_key_policy.rb +4 -0
  47. data/app/policies/root_policy.rb +3 -0
  48. data/app/policies/tenant_policy.rb +5 -0
  49. data/app/policies/user_policy.rb +33 -0
  50. data/app/resources/action_resource.rb +16 -0
  51. data/app/resources/credential_resource.rb +13 -0
  52. data/app/resources/group_resource.rb +8 -0
  53. data/app/resources/iam/application_resource.rb +7 -0
  54. data/app/resources/policy_resource.rb +9 -0
  55. data/app/resources/public_key_resource.rb +6 -0
  56. data/app/resources/root_resource.rb +14 -0
  57. data/app/resources/tenant_resource.rb +21 -0
  58. data/app/resources/user_resource.rb +25 -0
  59. data/app/views/layouts/mailer.html.erb +4 -0
  60. data/app/views/user_mailer/confirmation_instructions.html.erb +5 -0
  61. data/app/views/user_mailer/email_changed.html.erb +7 -0
  62. data/app/views/user_mailer/password_change.html.erb +3 -0
  63. data/app/views/user_mailer/reset_password_instructions.html.erb +106 -0
  64. data/app/views/user_mailer/team_welcome.html.erb +107 -0
  65. data/app/views/user_mailer/unlock_instructions.html.erb +7 -0
  66. data/config/environment.rb +0 -0
  67. data/config/initializers/devise.rb +311 -0
  68. data/config/locales/devise.en.yml +65 -0
  69. data/config/routes.rb +17 -0
  70. data/config/sidekiq.yml +5 -0
  71. data/config/spring.rb +3 -0
  72. data/db/migrate/20190101000001_create_policies.rb +11 -0
  73. data/db/migrate/20190101000002_create_actions.rb +13 -0
  74. data/db/migrate/20190101000003_create_policy_actions.rb +13 -0
  75. data/db/migrate/20190215214352_create_roots.rb +43 -0
  76. data/db/migrate/20190215214353_update_tenants.rb +10 -0
  77. data/db/migrate/20190215214355_create_credentials.rb +14 -0
  78. data/db/migrate/20190215214407_create_users.rb +50 -0
  79. data/db/migrate/20190215214409_create_user_credentials.rb +12 -0
  80. data/db/migrate/20190215214410_create_user_policy_joins.rb +12 -0
  81. data/db/migrate/20190215214411_create_groups.rb +11 -0
  82. data/db/migrate/20190215214412_create_user_groups.rb +12 -0
  83. data/db/migrate/20190215214413_create_group_policy_joins.rb +12 -0
  84. data/db/migrate/20190215214415_create_roles.rb +11 -0
  85. data/db/migrate/20190215214416_create_user_roles.rb +12 -0
  86. data/db/migrate/20190215214421_create_role_policy_joins.rb +12 -0
  87. data/db/migrate/20190924091536_add_display_properties_to_tenants.rb +5 -0
  88. data/db/migrate/20191021220135_create_public_keys.rb +10 -0
  89. data/db/migrate/20191120083154_add_confirmable_email_to_user.rb +9 -0
  90. data/db/seeds/development/tenants.seeds.rb +41 -0
  91. data/db/seeds/development/users.seeds.rb +67 -0
  92. data/lib/ros/api_token_strategy.rb +24 -0
  93. data/lib/ros/iam.rb +18 -0
  94. data/lib/ros/iam/console.rb +13 -0
  95. data/lib/ros/iam/engine.rb +51 -0
  96. data/lib/ros/iam/version.rb +7 -0
  97. data/lib/tasks/ros/iam_tasks.rake +51 -0
  98. metadata +209 -0
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserPolicyJoin < ApplicationRecord
4
+ belongs_to :user
5
+ belongs_to :policy
6
+
7
+ after_create :add_policy_to_user
8
+ after_destroy :remove_policy_from_user
9
+
10
+ def add_policy_to_user
11
+ user.attached_policies[policy.name] ||= 0
12
+ user.attached_policies[policy.name] += 1
13
+ user.save
14
+ end
15
+
16
+ def remove_policy_from_user
17
+ user.attached_policies[policy.name] -= 1
18
+ user.attached_policies.delete(policy.name) if user.attached_policies[policy.name].zero?
19
+ user.save
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserRole < Iam::ApplicationRecord
4
+ belongs_to :user
5
+ belongs_to :role
6
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ class BlackcombUserCreate < Ros::ActivityBase
6
+ step :valdidate_root_owner
7
+ failed :invalid_user, Output(:success) => End(:failure)
8
+ step :switch_tenant
9
+ failed :invalid_schema, Output(:success) => End(:failure)
10
+ step :create_or_find_blackcomb_user
11
+ failed :failed_to_create_user
12
+
13
+ private
14
+
15
+ def valdidate_root_owner(_ctx, params:, **)
16
+ Apartment::Tenant.current == 'public' && params[:current_user].root?
17
+ end
18
+
19
+ def invalid_user(_ctx, errors:, **)
20
+ errors.add(:user, 'Not a owner root')
21
+ end
22
+
23
+ def switch_tenant(_ctx, params:, **)
24
+ tenant = Tenant.find_by_schema_or_alias(params[:account_id])
25
+ return false unless tenant
26
+
27
+ tenant.switch!
28
+ true
29
+ end
30
+
31
+ def invalid_schema(_ctx, errors:, **)
32
+ errors.add(:account_id, 'Invalid account id')
33
+ end
34
+
35
+ def create_or_find_blackcomb_user(ctx, **)
36
+ ctx[:model] = User.find_or_initialize_by(username: 'blackcomb')
37
+
38
+ return true if ctx[:model].persisted?
39
+
40
+ password = SecureRandom.hex
41
+ ctx[:model].update(password: password, password_confirmation: password,
42
+ confirmed_at: Time.zone.today, attached_policies: { AdministratorAccess: 1 })
43
+ ctx[:model].save
44
+ end
45
+
46
+ def failed_to_create_user(ctx, model:, **)
47
+ ctx[:errors] = model.errors
48
+ end
49
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserCreate < Ros::ActivityBase
4
+ step :check_permission
5
+ failed :not_permitted, Output(:success) => End(:failure)
6
+ step :init
7
+ step :initialize_user
8
+ step :skip_confirmation_notification
9
+ step :generate_reset_passowrd_token
10
+ step :save__model, Output(:failure) => End(:failure)
11
+ step :create_relationships
12
+ step :send_welcome_email
13
+
14
+ private
15
+
16
+ def check_permission(_ctx, user:, **)
17
+ UserPolicy.new(user, User.new).create?
18
+ end
19
+
20
+ def not_permitted(_ctx, errors:, **)
21
+ errors.add(:user, 'not permitted to create a user')
22
+ end
23
+
24
+ def init(ctx, params:, **)
25
+ ctx[:relationships] = params.delete(:relationships)
26
+ true
27
+ end
28
+
29
+ def initialize_user(ctx, params:, **)
30
+ ctx[:model] = User.new(params)
31
+ end
32
+
33
+ def skip_confirmation_notification(_ctx, model:, **)
34
+ model.skip_confirmation_notification!
35
+ end
36
+
37
+ def generate_reset_passowrd_token(ctx, model:, **)
38
+ ctx[:reset_password_token], enc = Devise.token_generator.generate(model.class, :reset_password_token)
39
+
40
+ model.reset_password_token = enc
41
+ model.reset_password_sent_at = Time.now.utc
42
+ end
43
+
44
+ def create_relationships(_ctx, model:, relationships:, **)
45
+ return true if relationships&.dig(:groups, :data).blank?
46
+
47
+ model.groups << Group.where(id: relationships[:groups][:data].pluck(:id)).all
48
+ end
49
+
50
+ def send_welcome_email(_ctx, model:, reset_password_token:, **)
51
+ AccountMailer.team_welcome(model, reset_password_token).deliver_later
52
+ end
53
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActionPolicy < Iam::ApplicationPolicy; end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CredentialPolicy < Iam::ApplicationPolicy; end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GroupPolicy < Iam::ApplicationPolicy; end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iam
4
+ class ApplicationPolicy < ::ApplicationPolicy
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PolicyPolicy < Iam::ApplicationPolicy; end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PublicKeyPolicy < Iam::ApplicationPolicy
4
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RootPolicy < Iam::ApplicationPolicy; end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TenantPolicy < Iam::ApplicationPolicy
4
+ include Ros::TenantPolicyConcern
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserPolicy < Iam::ApplicationPolicy
4
+ # def index?
5
+ # user.action_permitted?(requested_action(__method__))
6
+ # # user.admin? || !record.published?
7
+ # end
8
+
9
+ # def self.zactions
10
+ # {
11
+ # Write: [
12
+ # 'AddUserToGroup'
13
+ # ]
14
+ # }
15
+ # end
16
+
17
+ # def update?
18
+ # super
19
+ # # does current_user have permission 'UpdateUser'
20
+ # end
21
+
22
+ # NOTE: Iam is a class in the api-client as are all namespaced models
23
+ # requested_action = Iam::Service.find_by(name: 'User').actions.find_by(name: 'DescribeUser')
24
+ # NOTE: The result should be cached as this value will not change frequently
25
+ # NOTE: There should be a way to break the cache in case the value does change
26
+ # def requested_action(method_name)
27
+ # Service.find_by(name: service_name).actions.find_by(name: action_name(method_name))
28
+ # end
29
+
30
+ # def action_name(method_name)
31
+ # method_map[method_name] + service_name
32
+ # end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActionResource < Iam::ApplicationResource
4
+ # caching
5
+ attributes :name, :resource, :action_type
6
+
7
+ def action_type
8
+ @model.type
9
+ end
10
+ end
11
+
12
+ # class ListActionResource < ActionResource; end
13
+
14
+ # class ReadActionResource < ActionResource; end
15
+
16
+ # class WriteActionResource < ActionResource; end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CredentialResource < Iam::ApplicationResource
4
+ attributes :access_key_id, :owner_type, :owner_id, :secret_access_key
5
+
6
+ filter :access_key_id
7
+
8
+ def self.descriptions
9
+ {
10
+ access_key_id: 'The access key'
11
+ }
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GroupResource < Iam::ApplicationResource
4
+ attributes :name
5
+ has_many :users
6
+
7
+ filter :name
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iam
4
+ class ApplicationResource < ::ApplicationResource
5
+ abstract
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PolicyResource < Iam::ApplicationResource
4
+ # caching
5
+ attributes :name
6
+ filter :name
7
+
8
+ has_many :actions
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PublicKeyResource < Iam::ApplicationResource
4
+ attributes :content, :user_id
5
+ has_one :user
6
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RootResource < Iam::ApplicationResource
4
+ attributes :email, :jwt_payload
5
+ attributes :attached_policies, :attached_actions
6
+
7
+ has_many :credentials
8
+
9
+ filter :email
10
+
11
+ def attached_policies; {} end
12
+
13
+ def attached_actions; {} end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TenantResource < Iam::ApplicationResource
4
+ attributes :account_id, :root_id, :alias, :name, :display_properties # :locale
5
+
6
+ filter :schema_name
7
+
8
+ def self.descriptions
9
+ {
10
+ schema_name: 'The name of the <h1>Schema</h1>'
11
+ }
12
+ end
13
+
14
+ def self.updatable_fields(context)
15
+ super - [:root_id]
16
+ end
17
+
18
+ def self.creatable_fields(context)
19
+ super - [:root_id]
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserResource < Iam::ApplicationResource
4
+ attributes :username, :api, :console, :time_zone, :properties,
5
+ :display_properties, :jwt_payload, :attached_policies,
6
+ :attached_actions, :email, :password, :password_confirmation, :unconfirmed_email
7
+
8
+ has_many :groups
9
+ has_many :credentials
10
+ has_many :public_keys
11
+
12
+ filters :username, :groups
13
+
14
+ def fetchable_fields
15
+ super - %i[password password_confirmation]
16
+ end
17
+
18
+ def self.creatable_fields(context)
19
+ super - %i[attached_policies attached_actions jwt_payload]
20
+ end
21
+
22
+ def self.updatable_fields(context)
23
+ super - %i[attached_policies attached_actions jwt_payload]
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ <html>
2
+ <head></head>
3
+ <body><%= yield %></body>
4
+ </html>
@@ -0,0 +1,5 @@
1
+ <p>Welcome <%= @resource.email %>!</p>
2
+
3
+ <p>You can confirm your account email through the link below:</p>
4
+
5
+ <p> <%= link_to 'Confirm my account', @confirmation_url %> </p>
@@ -0,0 +1,7 @@
1
+ <p>Hello <%= @email %>!</p>
2
+
3
+ <% if @resource.try(:unconfirmed_email?) %>
4
+ <p>We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.</p>
5
+ <% else %>
6
+ <p>We're contacting you to notify you that your email has been changed to <%= @resource.email %>.</p>
7
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <p>Hello <%= @resource.email %>!</p>
2
+
3
+ <p>We're contacting you to notify you that your password has been changed.</p>
@@ -0,0 +1,106 @@
1
+ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
2
+ <html lang="en">
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+
8
+ <title>PERX Reset Password</title>
9
+
10
+ <link href="https://fonts.googleapis.com/css?family=Roboto:400,500" rel="stylesheet" type="text/css">
11
+ <style type="text/css">
12
+ </style>
13
+ </head>
14
+ <body style="margin:0; padding:0; background-color:#F2F6FC;">
15
+ <center>
16
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#F2F6FC">
17
+ <tr>
18
+ <td align="center" height="100%" valign="top" width="100%">
19
+ <!--[if (gte mso 9)|(IE)]>
20
+ <table align="center" border="0" cellspacing="24" cellpadding="0" width="600">
21
+ <tr>
22
+ <td align="center" valign="top" width="600">
23
+ <![endif]-->
24
+ <table align="center" border="0" cellpadding="0" cellspacing="24" width="100%" style="max-width:600px;" bgcolor="#ffffff">
25
+ <tr>
26
+ <td align="left">
27
+ <img alt="Perx" src="https://cdn.uat.whistler.perxtech.io/dev1/global/assets/email/logo.png" width="96" height="34" style="width: 100%; max-width: 96px; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-weight: 500; font-size: 24px; line-height: 24px; letter-spacing: 0.18px; color: #2664ed; text-transform: lowercase; display: block; border: 0px;" border="0">
28
+ </td>
29
+ </tr>
30
+ <tr>
31
+ <td align="center">
32
+ <img alt="Illustration of user unlocking account" src="https://cdn.uat.whistler.perxtech.io/dev1/global/assets/email/email-reset.png" width="268" style="width: 100%; max-width: 268px; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 12px; line-height: 16px; letter-spacing: 0.4px; color: #7b7b7b; display: block; border: 0px;" border="0">
33
+ </td>
34
+ </tr>
35
+ <tr>
36
+ <td align="left" valign="top" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 24px; line-height: 24px; letter-spacing: 0.18px; color: #333333;">
37
+ Hi <%= @resource.username %>,
38
+ </td>
39
+ </tr>
40
+ <tr>
41
+ <td align="left" valign="top" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: #7b7b7b;">
42
+ A request has been made to reset your password. Simply click on the button below to reset it.
43
+ <b style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: #333333; font-weight: 500;">
44
+ This password reset is only valid for the next 24 hours.
45
+ </b>
46
+ </td>
47
+ </tr>
48
+ <tr>
49
+ <td align="center">
50
+ <table width="100%" border="0" cellspacing="0" cellpadding="0">
51
+ <tr>
52
+ <td align="center">
53
+ <table border="0" cellspacing="0" cellpadding="0" width="268" style="width: 100%; max-width: 268px;">
54
+ <tr>
55
+ <td align="center" width="100%" style="width: 100%; border-radius: 4px;" bgcolor="#2664ED"><a href="<%= @reset_url %>" target="_blank" style="font-size: 14px; letter-spacing: 0.75px; font-family: 'Roboto', Helvetica, Arial, sans-serif; color: #ffffff; font-weight: 500; text-decoration: none; border-radius: 4px; padding: 12px 24px; border: 1px solid #2664ED; display: block;"><!--[if mso]>&nbsp;<![endif]-->Reset Password<!--[if mso]>&nbsp;<![endif]--></a></td>
56
+ </tr>
57
+ </table>
58
+ </td>
59
+ </tr>
60
+ </table>
61
+ </td>
62
+ </tr>
63
+ <tr>
64
+ <td align="left" valign="top" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: #7b7b7b;">
65
+ If you did not make this request, you can safely ignore this email or
66
+ <a href="#" target="_blank" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: #2664ED; font-weight: 400; display: inline-block; text-decoration: none;">
67
+ contact support
68
+ </a>
69
+ if you have questions.
70
+ </td>
71
+ </tr>
72
+ <tr>
73
+ <td align="left" valign="top" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; color: #7b7b7b;">
74
+ Thank you, <br />
75
+ Team Perx
76
+ </td>
77
+ </tr>
78
+ <tr>
79
+ <td height="1" style="font-size:1px; line-height:1px;">&nbsp;</td>
80
+ </tr>
81
+ <tr>
82
+ <td height="1" style="font-size:1px; line-height:1px;" bgcolor="#e8e8e8">&nbsp;</td>
83
+ </tr>
84
+ <tr>
85
+ <td height="1" style="font-size:1px; line-height:1px;">&nbsp;</td>
86
+ </tr>
87
+ <tr>
88
+ <td align="left" valign="top" style="font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 16px; letter-spacing: 0.4px; color: #7b7b7b;">
89
+ &copy; 2019 Perx Technologies. All rights reserved.<br /><br />
90
+ 20 Maxwell Road #02-01 <br />
91
+ Maxwell House, 069113
92
+ </td>
93
+ </tr>
94
+
95
+ </table>
96
+ <!--[if (gte mso 9)|(IE)]>
97
+ </td>
98
+ </tr>
99
+ </table>
100
+ <![endif]-->
101
+ </td>
102
+ </tr>
103
+ </table>
104
+ </center>
105
+ </body>
106
+ </html>