Python 2.7 is very deprecated on AWS Lambda now
Just over a year after doing this work and posting this, Amazon announced full deprecation dates for Python 2.7 on Lambda. No new Lambda functions using Python 2.7 can be created after July 15, 2021. No existing Lambda functions using Python 2.7 can be updated after September 30, 2021. Maybe try packaging it up in a Docker container if you still need this?
Read-only Moin 1.9.x on AWS Lambda
About this HowTo
- MoinMoin versions
- 1.9.10
- Platforms
- AWS Lambda
After retiring a fifteen year old Moin install, I set up a read-only copy on AWS Lambda to privately preserve it in full fidelity without having the overhead of managing or maintaining a server. It should run inexpensively, behind HTTP basic access authentication and SSL, for as long as Amazon supports Python 2.7.
Caveats
Setting up an Amazon Web Services account is out of scope for this document. Choosing a serverless framework is out of scope for this document. Creating a local, working Python 2.7 environment for setup and testing is out of scope for this document.
Requirements
- An AWS account supporting Lambda
- Moin 1.9.10
- Zappa
- Passlib
zappa_settings.json
1 {
2 "wiki": {
3 "app_function": "moin_wsgi.application",
4 "profile_name": "YOUR-PROFILE-NAME",
5 "project_name": "YOUR-WIKI-NAME",
6 "runtime": "python2.7",
7 "s3_bucket": "YOUR-S3-BUCKET",
8 "aws_region": "YOUR-AWS-REGION",
9 "keep_warm": false,
10 "slim_handler": true,
11 "authorizer": { "function": "lambda-authorizer-basic-auth.lambda_handler" },
12 }
13 }
wikiconfig_local.py
1 from wikiconfig import LocalConfig
2 import os
3 from MoinMoin.config import multiconfig, url_prefix_static
4
5 class Config(LocalConfig):
6 os.environ['SCRIPT_NAME'] = '/wiki'
7 url_prefix_static = '/wiki' + url_prefix_static
8 logo_string = u'<img src="%s/common/moinmoin.png" alt="MoinMoin Logo">' % url_prefix_static
9 actions_excluded = multiconfig.DefaultConfig.actions_excluded + ['newaccount']
10 page_front_page = u'YOUR-FRONT-PAGE-NAME' # change to some better value
11 sitename = u'YOUR-SITE-NAME'
12 DesktopEdition = False # give all local users full powers (every user is superuser)
13 acl_rights_default = u"All:read"
14 secrets = 'YOUR-SECRET-STRING'
lambda-authorizer-basic-auth.py
Derived from https://github.com/dougalb/lambda-authorizer-basic-auth/blob/master/lambda_authorizer_basic_auth/app.py
1 """
2 Copyright 2020 Vitorio Miliano.
3
4 Copyright 2018 Amazon.com, Inc. and its affiliates. All Rights Reserved.
5
6 Licensed under the MIT License. See the LICENSE accompanying this file
7 for the specific language governing permissions and limitations under
8 the License.
9 """
10 from __future__ import print_function
11
12 import os
13 import re
14 import json
15 import logging
16 import base64
17
18 from passlib.apache import HtpasswdFile
19 ht = HtpasswdFile("YOUR-HTPASSWD-FILE")
20 log_level = logging.INFO
21 log = logging.getLogger(__name__)
22 logging.getLogger().setLevel(log_level)
23
24 def lambda_handler(event, context):
25 log.debug("Event: " + json.dumps(event))
26
27 # Ensure the incoming Lambda event is for a request authorizer
28 if event['type'] != 'TOKEN':
29 raise Exception('Unauthorized')
30
31 try:
32 # Get the username:password hash from the authorization header
33 username_password_hash = event['authorizationToken'].split()[1]
34 log.debug("username_password_hash: " + username_password_hash)
35
36 # Decode username_password_hash
37 username, password = base64.standard_b64decode(username_password_hash).split(':', 1)
38 log.debug("username: " + username)
39
40 if username in ht.users():
41 if ht.check_password(username, password):
42 log.info("password match for: " + username)
43
44 # Set the principalId to the accountId making the authorizer call
45 principalId = username
46
47 tmp = event['methodArn'].split(':')
48 apiGatewayArnTmp = tmp[5].split('/')
49 awsAccountId = tmp[4]
50
51 policy = AuthPolicy(principalId, awsAccountId)
52 policy.restApiId = apiGatewayArnTmp[0]
53 policy.region = tmp[3]
54 policy.stage = apiGatewayArnTmp[1]
55
56 policy.allowAllMethods()
57
58 # Finally, build the policy
59 authResponse = policy.build()
60 log.debug("authResponse: " + json.dumps(authResponse))
61
62 return authResponse
63 else:
64 log.info("password does not match for: " + username)
65 raise Exception('Unauthorized')
66 else:
67 log.info("Did not find username: " + username)
68 raise Exception('Unauthorized')
69 except Exception:
70 raise Exception('Unauthorized')
71
72
73 class HttpVerb:
74 GET = "GET"
75 POST = "POST"
76 PUT = "PUT"
77 PATCH = "PATCH"
78 HEAD = "HEAD"
79 DELETE = "DELETE"
80 OPTIONS = "OPTIONS"
81 ALL = "*"
82
83
84 class AuthPolicy(object):
85 awsAccountId = ""
86 """The AWS account id the policy will be generated for. This is used to create the method ARNs."""
87 principalId = ""
88 """The principal used for the policy, this should be a unique identifier for the end user."""
89 version = "2012-10-17"
90 """The policy version used for the evaluation. This should always be '2012-10-17'"""
91 pathRegex = "^[/.a-zA-Z0-9-\*]+$"
92 """The regular expression used to validate resource paths for the policy"""
93
94 """these are the internal lists of allowed and denied methods. These are lists
95 of objects and each object has 2 properties: A resource ARN and a nullable
96 conditions statement.
97 the build method processes these lists and generates the approriate
98 statements for the final policy"""
99 allowMethods = []
100 denyMethods = []
101
102 restApiId = "*"
103 """The API Gateway API id. By default this is set to '*'"""
104 region = "*"
105 """The region where the API is deployed. By default this is set to '*'"""
106 stage = "*"
107 """The name of the stage used in the policy. By default this is set to '*'"""
108
109 def __init__(self, principal, awsAccountId):
110 self.awsAccountId = awsAccountId
111 self.principalId = principal
112 self.allowMethods = []
113 self.denyMethods = []
114
115 def _addMethod(self, effect, verb, resource, conditions):
116 """Adds a method to the internal lists of allowed or denied methods. Each object in
117 the internal list contains a resource ARN and a condition statement. The condition
118 statement can be null."""
119 if verb != "*" and not hasattr(HttpVerb, verb):
120 raise NameError("Invalid HTTP verb " + verb + ". Allowed verbs in HttpVerb class")
121 resourcePattern = re.compile(self.pathRegex)
122 if not resourcePattern.match(resource):
123 raise NameError("Invalid resource path: " + resource + ". Path should match " + self.pathRegex)
124
125 if resource[:1] == "/":
126 resource = resource[1:]
127
128 resourceArn = ("arn:aws:execute-api:" +
129 self.region + ":" +
130 self.awsAccountId + ":" +
131 self.restApiId + "/" +
132 self.stage + "/" +
133 verb + "/" +
134 resource)
135
136 if effect.lower() == "allow":
137 self.allowMethods.append({
138 'resourceArn': resourceArn,
139 'conditions': conditions
140 })
141 elif effect.lower() == "deny":
142 self.denyMethods.append({
143 'resourceArn': resourceArn,
144 'conditions': conditions
145 })
146
147 def _getEmptyStatement(self, effect):
148 """Returns an empty statement object prepopulated with the correct action and the
149 desired effect."""
150 statement = {
151 'Action': 'execute-api:Invoke',
152 'Effect': effect[:1].upper() + effect[1:].lower(),
153 'Resource': []
154 }
155
156 return statement
157
158 def _getStatementForEffect(self, effect, methods):
159 """This function loops over an array of objects containing a resourceArn and
160 conditions statement and generates the array of statements for the policy."""
161 statements = []
162
163 if len(methods) > 0:
164 statement = self._getEmptyStatement(effect)
165
166 for curMethod in methods:
167 if curMethod['conditions'] is None or len(curMethod['conditions']) == 0:
168 statement['Resource'].append(curMethod['resourceArn'])
169 else:
170 conditionalStatement = self._getEmptyStatement(effect)
171 conditionalStatement['Resource'].append(curMethod['resourceArn'])
172 conditionalStatement['Condition'] = curMethod['conditions']
173 statements.append(conditionalStatement)
174
175 statements.append(statement)
176
177 return statements
178
179 def allowAllMethods(self):
180 """Adds a '*' allow to the policy to authorize access to all methods of an API"""
181 self._addMethod("Allow", HttpVerb.ALL, "*", [])
182
183 def denyAllMethods(self):
184 """Adds a '*' allow to the policy to deny access to all methods of an API"""
185 self._addMethod("Deny", HttpVerb.ALL, "*", [])
186
187 def allowMethod(self, verb, resource):
188 """Adds an API Gateway method (Http verb + Resource path) to the list of allowed
189 methods for the policy"""
190 self._addMethod("Allow", verb, resource, [])
191
192 def denyMethod(self, verb, resource):
193 """Adds an API Gateway method (Http verb + Resource path) to the list of denied
194 methods for the policy"""
195 self._addMethod("Deny", verb, resource, [])
196
197 def allowMethodWithConditions(self, verb, resource, conditions):
198 """Adds an API Gateway method (Http verb + Resource path) to the list of allowed
199 methods and includes a condition for the policy statement. More on AWS policy
200 conditions here: http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
201 self._addMethod("Allow", verb, resource, conditions)
202
203 def denyMethodWithConditions(self, verb, resource, conditions):
204 """Adds an API Gateway method (Http verb + Resource path) to the list of denied
205 methods and includes a condition for the policy statement. More on AWS policy
206 conditions here: http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
207 self._addMethod("Deny", verb, resource, conditions)
208
209 def build(self):
210 """Generates the policy document based on the internal lists of allowed and denied
211 conditions. This will generate a policy with two main statements for the effect:
212 one statement for Allow and one statement for Deny.
213 Methods that includes conditions will have their own statement in the policy."""
214 if ((self.allowMethods is None or len(self.allowMethods) == 0) and
215 (self.denyMethods is None or len(self.denyMethods) == 0)):
216 raise NameError("No statements defined for the policy")
217
218 policy = {
219 'principalId': self.principalId,
220 'policyDocument': {
221 'Version': self.version,
222 'Statement': []
223 }
224 }
225
226 policy['policyDocument']['Statement'].extend(self._getStatementForEffect("Allow", self.allowMethods))
227 policy['policyDocument']['Statement'].extend(self._getStatementForEffect("Deny", self.denyMethods))
228
229 return policy
Example console output
1 $ zappa update wiki
2 Calling update for stage wiki..
3 Downloading and installing dependencies..
4 Packaging project as gzipped tarball.
5 DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. Please upgrade your Python as Python 2.7 won't be maintained after that date. A future version of pip will drop support for Python 2.7. More details about Python 2 support in pip, can be found at https://pip.pypa.io/en/latest/development/release-process/#python-2-support
6 WARNING: Target directory ██████████/test04/handler_venv/lib/python2.7/site-packages/zappa already exists. Specify --upgrade to force replacement.
7 WARNING: Target directory ██████████/test04/handler_venv/lib/python2.7/site-packages/zappa-0.48.2.dist-info already exists. Specify --upgrade to force replacement.
8 Downloading and installing dependencies..
9 Packaging project as zip.
10 Uploading test04-wiki-1578451170.tar.gz (107.2MiB)..
11 100%|████████████████████████████████████████| 112M/112M [00:41<00:00, 2.01MB/s]
12 Uploading handler_test04-wiki-1578451262.zip (11.5MiB)..
13 100%|██████████████████████████████████████| 12.1M/12.1M [00:04<00:00, 2.19MB/s]
14 Updating Lambda function code..
15 Updating Lambda function configuration..
16 Uploading test04-wiki-template-1578451329.json (2.1KiB)..
17 100%|██████████████████████████████████████| 2.15K/2.15K [00:00<00:00, 15.2KB/s]
18 Deploying API Gateway..
19 Your updated Zappa deployment is live!: https://██████████.execute-api.us-east-1.amazonaws.com/wiki
20 ...
21